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,219 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SynapseLoginFlowsCheckConfig holds the parameters for polling the Synapse
|
||||
// login-flows endpoint and verifying that the MAS (Matrix Authentication
|
||||
// Service) SSO flow is active.
|
||||
type SynapseLoginFlowsCheckConfig struct {
|
||||
HomeserverURL string // Public URL of the homeserver (e.g. https://matrix.example.com)
|
||||
ExpectedSsoIdpID string // IdP id to find in m.login.sso.identity_providers[].id (empty = only check SSO presence)
|
||||
MaxRetries int // Number of attempts before giving up (default: 10)
|
||||
RetryDelaySeconds int // Seconds to wait between attempts (default: 3)
|
||||
HttpTimeoutSeconds int // Per-request HTTP timeout in seconds (default: 5)
|
||||
}
|
||||
|
||||
// SynapseLoginFlowsCheckResult contains the parsed state of the login-flows
|
||||
// endpoint after the last successful (or final failed) attempt.
|
||||
type SynapseLoginFlowsCheckResult struct {
|
||||
Flows []string // All flow types returned (e.g. ["m.login.sso"])
|
||||
SsoPresent bool // true if "m.login.sso" is in Flows
|
||||
IdpFound bool // true if ExpectedSsoIdpID was found (or ExpectedSsoIdpID is empty and SsoPresent)
|
||||
PasswordEnabled bool // true if "m.login.password" is in Flows
|
||||
LastResponseJSON string // Raw JSON body from the last HTTP response
|
||||
AttemptsUsed int // Number of HTTP attempts made
|
||||
}
|
||||
|
||||
// loginFlowsResponse is the structure returned by
|
||||
// GET /_matrix/client/v3/login
|
||||
type loginFlowsResponse struct {
|
||||
Flows []loginFlow `json:"flows"`
|
||||
}
|
||||
|
||||
type loginFlow struct {
|
||||
Type string `json:"type"`
|
||||
IdentityProviders []idpProvider `json:"identity_providers,omitempty"`
|
||||
}
|
||||
|
||||
type idpProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SynapseLoginFlowsCheck polls GET {HomeserverURL}/_matrix/client/v3/login
|
||||
// and checks that the SSO/MAS flow is present and password login is disabled.
|
||||
// It retries up to MaxRetries times with RetryDelaySeconds delay between each.
|
||||
//
|
||||
// Success condition:
|
||||
// - "m.login.sso" is present in flows
|
||||
// - ExpectedSsoIdpID found in identity_providers (skipped when empty)
|
||||
// - "m.login.password" is NOT present
|
||||
//
|
||||
// Returns the result from the last attempt. On convergence failure it also
|
||||
// returns a non-nil error describing the final state.
|
||||
func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error) {
|
||||
if cfg.HomeserverURL == "" {
|
||||
return SynapseLoginFlowsCheckResult{}, fmt.Errorf("synapse_login_flows_check: HomeserverURL must not be empty")
|
||||
}
|
||||
cfg.HomeserverURL = strings.TrimRight(cfg.HomeserverURL, "/")
|
||||
|
||||
if cfg.MaxRetries <= 0 {
|
||||
cfg.MaxRetries = 10
|
||||
}
|
||||
if cfg.RetryDelaySeconds < 0 {
|
||||
cfg.RetryDelaySeconds = 3
|
||||
}
|
||||
if cfg.HttpTimeoutSeconds <= 0 {
|
||||
cfg.HttpTimeoutSeconds = 5
|
||||
}
|
||||
|
||||
endpoint := cfg.HomeserverURL + "/_matrix/client/v3/login"
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Duration(cfg.HttpTimeoutSeconds) * time.Second,
|
||||
}
|
||||
|
||||
var result SynapseLoginFlowsCheckResult
|
||||
|
||||
for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
|
||||
result.AttemptsUsed = attempt
|
||||
|
||||
resp, body, parseErr := fetchAndParse(httpClient, endpoint)
|
||||
result.LastResponseJSON = body
|
||||
|
||||
if parseErr != nil {
|
||||
// On the last attempt, surface the parse/network error
|
||||
if attempt == cfg.MaxRetries {
|
||||
return result, fmt.Errorf("synapse_login_flows_check: attempt %d/%d: %w", attempt, cfg.MaxRetries, parseErr)
|
||||
}
|
||||
sleepSeconds(cfg.RetryDelaySeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result from parsed response
|
||||
result.Flows = extractFlowTypes(resp.Flows)
|
||||
result.SsoPresent = containsFlow(resp.Flows, "m.login.sso")
|
||||
result.PasswordEnabled = containsFlow(resp.Flows, "m.login.password")
|
||||
|
||||
if result.SsoPresent {
|
||||
if cfg.ExpectedSsoIdpID == "" {
|
||||
result.IdpFound = true
|
||||
} else {
|
||||
result.IdpFound = findIdp(resp.Flows, cfg.ExpectedSsoIdpID)
|
||||
}
|
||||
} else {
|
||||
result.IdpFound = false
|
||||
}
|
||||
|
||||
// Check success condition
|
||||
if result.SsoPresent && result.IdpFound && !result.PasswordEnabled {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if attempt < cfg.MaxRetries {
|
||||
sleepSeconds(cfg.RetryDelaySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted retries — build a descriptive error
|
||||
msg := buildConvergenceError(result, cfg)
|
||||
return result, fmt.Errorf("synapse_login_flows_check: %s", msg)
|
||||
}
|
||||
|
||||
// fetchAndParse performs one HTTP GET and returns the parsed response plus the
|
||||
// raw body. On any error (network, status, JSON) the raw body may be partial.
|
||||
func fetchAndParse(client *http.Client, url string) (*loginFlowsResponse, string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, body, fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, body)
|
||||
}
|
||||
|
||||
var parsed loginFlowsResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, body, fmt.Errorf("json unmarshal: %w", err)
|
||||
}
|
||||
return &parsed, body, nil
|
||||
}
|
||||
|
||||
// extractFlowTypes returns the "type" field of each flow entry.
|
||||
func extractFlowTypes(flows []loginFlow) []string {
|
||||
types := make([]string, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
types = append(types, f.Type)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// containsFlow reports whether any flow entry has the given type.
|
||||
func containsFlow(flows []loginFlow, flowType string) bool {
|
||||
for _, f := range flows {
|
||||
if f.Type == flowType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findIdp reports whether any identity_provider in a "m.login.sso" flow has
|
||||
// the given id.
|
||||
func findIdp(flows []loginFlow, idpID string) bool {
|
||||
for _, f := range flows {
|
||||
if f.Type != "m.login.sso" {
|
||||
continue
|
||||
}
|
||||
for _, idp := range f.IdentityProviders {
|
||||
if idp.ID == idpID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildConvergenceError assembles a human-readable error message describing
|
||||
// why the final state is not the expected post-migration state.
|
||||
func buildConvergenceError(r SynapseLoginFlowsCheckResult, cfg SynapseLoginFlowsCheckConfig) string {
|
||||
var parts []string
|
||||
if !r.SsoPresent {
|
||||
parts = append(parts, "m.login.sso not present")
|
||||
}
|
||||
if cfg.ExpectedSsoIdpID != "" && !r.IdpFound {
|
||||
parts = append(parts, fmt.Sprintf("IdP %q not found in identity_providers", cfg.ExpectedSsoIdpID))
|
||||
}
|
||||
if r.PasswordEnabled {
|
||||
parts = append(parts, "m.login.password still enabled (MSC3861 not fully applied)")
|
||||
}
|
||||
reason := strings.Join(parts, "; ")
|
||||
return fmt.Sprintf("MAS migration not confirmed after %d attempt(s): %s", r.AttemptsUsed, reason)
|
||||
}
|
||||
|
||||
// sleepSeconds sleeps for n seconds. Extracted for test patching via a
|
||||
// package-level variable.
|
||||
var sleepSeconds = func(n int) {
|
||||
if n > 0 {
|
||||
time.Sleep(time.Duration(n) * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: synapse_login_flows_check
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error)"
|
||||
description: "Verifica que el endpoint /_matrix/client/v3/login del homeserver Synapse devuelve m.login.sso con el IdP de MAS esperado y que m.login.password está desactivado. Hace polling con reintentos hasta confirmar el estado post-migración o agotar los intentos."
|
||||
tags: [matrix, mas, synapse, login, healthcheck, migration, mas-migration, infra, matrix-mas]
|
||||
params:
|
||||
- name: HomeserverURL
|
||||
desc: "URL pública del homeserver (ej. https://matrix-af2f3d.organic-machine.com). Sin trailing slash."
|
||||
- name: ExpectedSsoIdpID
|
||||
desc: "Identificador del IdP MAS esperado en m.login.sso.identity_providers[].id (ej. oidc-mas). Vacío = solo verificar que m.login.sso exista, sin comprobar IdP concreto."
|
||||
- name: MaxRetries
|
||||
desc: "Número máximo de intentos HTTP antes de abortar. Default: 10."
|
||||
- name: RetryDelaySeconds
|
||||
desc: "Segundos de espera entre intentos. Default: 3. Synapse tarda 10-30s en levantar tras restart."
|
||||
- name: HttpTimeoutSeconds
|
||||
desc: "Timeout HTTP por intento en segundos. Default: 5."
|
||||
output: "SynapseLoginFlowsCheckResult{Flows, SsoPresent, IdpFound, PasswordEnabled, LastResponseJSON, AttemptsUsed}. Error nil = migración confirmada. Error CONVERGENCE_FAILED = no convergió tras MaxRetries."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "io", "net/http", "strings", "time"]
|
||||
tested: true
|
||||
tests:
|
||||
- "SSO + IdP expected -> success on first attempt"
|
||||
- "legacy response then SSO on 3rd attempt -> success after retries"
|
||||
- "response never changes -> error after maxRetries"
|
||||
- "HTTP timeout -> error"
|
||||
- "malformed JSON -> error"
|
||||
test_file_path: "functions/infra/synapse_login_flows_check_test.go"
|
||||
file_path: "functions/infra/synapse_login_flows_check.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 3,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil && res.SsoPresent && !res.PasswordEnabled {
|
||||
fmt.Printf("MAS migration confirmed after %d attempt(s)\n", res.AttemptsUsed)
|
||||
// Continue with post-migration smoke tests
|
||||
} else if err != nil {
|
||||
fmt.Printf("Migration NOT confirmed: %s\n", err.Message)
|
||||
fmt.Printf("Last response: %s\n", res.LastResponseJSON)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar en el paso 6 del issue 0162 (migración Synapse→MAS), inmediatamente tras reiniciar Synapse con MSC3861 activado. También útil como `e2e_check` continuo en `app.md` del servicio Synapse para detectar regresiones (ej. alguien comenta `msc3861.enabled: true` por error y vuelve a activar password login).
|
||||
|
||||
```yaml
|
||||
# En app.md del servicio matrix:
|
||||
e2e_checks:
|
||||
- id: mas_login_flows
|
||||
cmd: "go run . -check-login-flows https://matrix-af2f3d.organic-machine.com oidc-mas"
|
||||
expect_stdout_contains: "MAS migration confirmed"
|
||||
timeout_s: 60
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Synapse tarda 10-30s en levantar** tras restart — los defaults (MaxRetries=10, RetryDelaySeconds=3) cubren 30s de espera total.
|
||||
- **PasswordEnabled == true post-migración**: probablemente `password_config.enabled: false` no se aplicó en `homeserver.yaml` o fue sobreescrito por include. Verificar config antes de reintentar.
|
||||
- **IdP id incorrecto**: el id del IdP depende de `mas/config.yaml` → sección `matrix.homeserver`. Verificar el valor exacto con `GET /_matrix/client/v3/login` manual antes de pasar a `ExpectedSsoIdpID`.
|
||||
- **TLS no válido**: si el certificado del HomeserverURL no es verificable, `net/http` retorna error de TLS — la función lo propaga como FETCH_ERROR con el mensaje original de Go (no lo ignora silenciosamente).
|
||||
- **Non-200 responses**: cualquier status HTTP != 200 se trata como error de fetch y dispara reintento.
|
||||
- **ExpectedSsoIdpID vacío**: solo verifica presencia de `m.login.sso` y ausencia de `m.login.password`. Suficiente para validación rápida; usar el ID completo para health-check de producción.
|
||||
@@ -0,0 +1,196 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loginFlowsJSON builds a minimal /_matrix/client/v3/login response body.
|
||||
func loginFlowsJSON(flows []loginFlow) string {
|
||||
b, _ := json.Marshal(loginFlowsResponse{Flows: flows})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// masFlows returns a typical post-migration response: only SSO with one IdP.
|
||||
func masFlows(idpID string) []loginFlow {
|
||||
return []loginFlow{
|
||||
{
|
||||
Type: "m.login.sso",
|
||||
IdentityProviders: []idpProvider{
|
||||
{ID: idpID, Name: "MAS"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// legacyFlows returns a pre-migration response: password + application_service.
|
||||
func legacyFlows() []loginFlow {
|
||||
return []loginFlow{
|
||||
{Type: "m.login.password"},
|
||||
{Type: "m.login.application_service"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseLoginFlowsCheck(t *testing.T) {
|
||||
// Disable real sleep during tests
|
||||
origSleep := sleepSeconds
|
||||
sleepSeconds = func(int) {}
|
||||
t.Cleanup(func() { sleepSeconds = origSleep })
|
||||
|
||||
t.Run("SSO + IdP expected -> success on first attempt", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 5,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if !res.IdpFound {
|
||||
t.Error("IdpFound should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
if res.AttemptsUsed != 1 {
|
||||
t.Errorf("expected 1 attempt, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if len(res.LastResponseJSON) == 0 {
|
||||
t.Error("LastResponseJSON should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy response then SSO on 3rd attempt -> success after retries", func(t *testing.T) {
|
||||
var callCount int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := int(atomic.AddInt32(&callCount, 1))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if n < 3 {
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
} else {
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response never changes -> error after maxRetries", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 3,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after max retries, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MAS migration not confirmed") {
|
||||
t.Errorf("expected 'MAS migration not confirmed' in error message, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts used, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be true (legacy still active)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP timeout -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Deliberately hang longer than the 1s timeout
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 1,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on timeout, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "synapse_login_flows_check") {
|
||||
t.Errorf("expected error to contain function name, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{not valid json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "json unmarshal") {
|
||||
t.Errorf("expected json unmarshal error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: synapse_msc3861_enable
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error)"
|
||||
description: "Edita homeserver.yaml de Synapse activando el bloque matrix_authentication_service (MSC3861/MAS), asegura experimental_features.msc3861.enabled=true y password_config.enabled=false. Preserva comentarios con yaml.v3 Node API. Hace backup automático previo y devuelve diff unified."
|
||||
tags: [matrix, mas, synapse, msc3861, migration, mas-migration, infra, yaml, matrix-mas]
|
||||
params:
|
||||
- name: HomeserverYamlPath
|
||||
desc: "Ruta absoluta al homeserver.yaml en disco local (normalmente copiado del VPS con scp antes de llamar esta función)"
|
||||
- name: MasEndpoint
|
||||
desc: "URL interna del servicio MAS (ej. http://mas:8080/). Debe empezar con http:// o https://"
|
||||
- name: MasSecret
|
||||
desc: "Shared secret hex de exactamente 64 caracteres (32 bytes) que debe coincidir con mas/config.yaml::matrix.secret"
|
||||
- name: BackupDir
|
||||
desc: "Directorio donde guardar el backup del archivo original (se crea con mkdir -p si no existe). Ej: /tmp/synapse_backups"
|
||||
- name: DryRun
|
||||
desc: "Si true, sólo computa el diff sin escribir archivos ni crear backup"
|
||||
output: "SynapseMsc3861Result con BackupPath (vacío si DryRun), LinesAdded, LinesRemoved y Diff (unified diff string)"
|
||||
uses_functions: []
|
||||
uses_types: ["error_go_core"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["gopkg.in/yaml.v3"]
|
||||
tested: true
|
||||
tests:
|
||||
- "commented mas block becomes active"
|
||||
- "already active mas block gets updated values"
|
||||
- "no mas block inserts block at end"
|
||||
- "dry run does not write file"
|
||||
test_file_path: "functions/infra/synapse_msc3861_enable_test.go"
|
||||
file_path: "functions/infra/synapse_msc3861_enable.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: "/tmp/synapse_data/homeserver.yaml",
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2",
|
||||
BackupDir: "/tmp/synapse_backups",
|
||||
DryRun: true,
|
||||
}
|
||||
res, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Diff:\n%s\n", res.Diff)
|
||||
fmt.Printf("Lines added: %d, removed: %d\n", res.LinesAdded, res.LinesRemoved)
|
||||
|
||||
// Para aplicar los cambios: DryRun: false
|
||||
// res.BackupPath contiene la ruta del backup creado.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Paso 3 de la migración 0162 (Synapse → MAS auth provider): después de copiar `homeserver.yaml` del VPS a disco local con `scp`, antes de copiarlo de vuelta con `scp` y hacer `systemctl restart matrix-synapse`. Usar `DryRun: true` primero para revisar el diff antes de escribir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **yaml.v3 Node API obligatorio**: el YAML de Synapse contiene comentarios críticos de configuración. Usar `yaml.Unmarshal` plano los elimina. Esta función usa la API de nodos para la sección `experimental_features` y edición line-level para los bloques `matrix_authentication_service` y `password_config`.
|
||||
- **MasSecret debe ser exacto**: debe coincidir byte a byte con `mas/config.yaml::matrix.secret`. Un carácter diferente hace que Synapse rechace todas las peticiones MAS con 401.
|
||||
- **Nunca editar in-place en el VPS activo**: editar el archivo mientras Synapse lo lee puede producir YAML corrupto en memoria. El flujo correcto es: `scp vps:/etc/matrix-synapse/homeserver.yaml /tmp/` → `SynapseMsc3861Enable(DryRun: false)` → `scp /tmp/homeserver.yaml vps:/etc/matrix-synapse/` → `systemctl restart matrix-synapse`.
|
||||
- **MasSecret formato**: exactamente 64 caracteres hexadecimales en minúsculas (32 bytes). La validación rechaza mayúsculas y longitudes incorrectas.
|
||||
- **Idempotencia**: aplicar la función dos veces sobre el mismo archivo produce el mismo resultado final (el segundo pase actualiza valores ya existentes).
|
||||
@@ -0,0 +1,332 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalHomeserverYAML is a realistic minimal homeserver.yaml fixture.
|
||||
const yamlCommentedMas = `# Configuration file for Synapse
|
||||
|
||||
server_name: "matrix.example.com"
|
||||
pid_file: /var/run/matrix-synapse/homeserver.pid
|
||||
|
||||
listeners:
|
||||
- port: 8448
|
||||
type: http
|
||||
|
||||
# matrix_authentication_service:
|
||||
# enabled: true
|
||||
# endpoint: "http://mas:8080/"
|
||||
# secret: "changeme"
|
||||
|
||||
experimental_features:
|
||||
some_other_flag: true
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
`
|
||||
|
||||
const yamlActiveMas = `server_name: "matrix.example.com"
|
||||
|
||||
matrix_authentication_service:
|
||||
enabled: false
|
||||
endpoint: "http://old-mas:9090/"
|
||||
secret: "oldsecret"
|
||||
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: false
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
`
|
||||
|
||||
const yamlNoMasBlock = `server_name: "matrix.example.com"
|
||||
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: false
|
||||
`
|
||||
|
||||
const yamlNoExperimentalFeatures = `server_name: "matrix.example.com"
|
||||
|
||||
# matrix_authentication_service:
|
||||
# enabled: false
|
||||
`
|
||||
|
||||
const testSecret = "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2"
|
||||
|
||||
// writeTempYAML writes content to a temp dir and returns the file path.
|
||||
func writeTempYAML(t *testing.T, content string) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "homeserver.yaml")
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
return p, dir
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861Enable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yamlContent string
|
||||
dryRun bool
|
||||
wantMasActive bool
|
||||
wantPwdOff bool
|
||||
wantMsc3861 bool
|
||||
wantNoBackup bool // true when DryRun
|
||||
}{
|
||||
{
|
||||
name: "commented mas block becomes active",
|
||||
yamlContent: yamlCommentedMas,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "already active mas block gets updated values",
|
||||
yamlContent: yamlActiveMas,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "no mas block inserts block at end",
|
||||
yamlContent: yamlNoMasBlock,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "dry run does not write file",
|
||||
yamlContent: yamlNoExperimentalFeatures,
|
||||
dryRun: true,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
wantNoBackup: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
yamlPath, tmpDir := writeTempYAML(t, tc.yamlContent)
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: yamlPath,
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: testSecret,
|
||||
BackupDir: backupDir,
|
||||
DryRun: tc.dryRun,
|
||||
}
|
||||
|
||||
result, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SynapseMsc3861Enable returned error: %v", err)
|
||||
}
|
||||
|
||||
// Check backup.
|
||||
if tc.wantNoBackup {
|
||||
if result.BackupPath != "" {
|
||||
t.Errorf("DryRun=true but BackupPath=%q (expected empty)", result.BackupPath)
|
||||
}
|
||||
} else {
|
||||
if result.BackupPath == "" {
|
||||
t.Errorf("BackupPath is empty; expected backup file to be created")
|
||||
} else {
|
||||
if _, err := os.Stat(result.BackupPath); err != nil {
|
||||
t.Errorf("backup file does not exist at %q: %v", result.BackupPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the content to check: written file (non-DryRun) or diff (DryRun).
|
||||
var finalContent string
|
||||
if tc.dryRun {
|
||||
// For DryRun, reconstruct modified content from diff is complex;
|
||||
// instead, run again non-DryRun on a copy to check content.
|
||||
yamlPath2, tmpDir2 := writeTempYAML(t, tc.yamlContent)
|
||||
cfg2 := cfg
|
||||
cfg2.HomeserverYamlPath = yamlPath2
|
||||
cfg2.BackupDir = filepath.Join(tmpDir2, "backups")
|
||||
cfg2.DryRun = false
|
||||
_, err2 := SynapseMsc3861Enable(cfg2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("non-DryRun copy returned error: %v", err2)
|
||||
}
|
||||
fc, err := os.ReadFile(yamlPath2)
|
||||
if err != nil {
|
||||
t.Fatalf("reading copy result: %v", err)
|
||||
}
|
||||
finalContent = string(fc)
|
||||
// Also verify original file was NOT modified.
|
||||
orig, _ := os.ReadFile(yamlPath)
|
||||
if string(orig) != tc.yamlContent {
|
||||
t.Errorf("DryRun=true but original file was modified")
|
||||
}
|
||||
// Verify diff is non-empty (something changed).
|
||||
if result.Diff == "" {
|
||||
t.Errorf("DryRun=true: expected non-empty Diff for modified content")
|
||||
}
|
||||
} else {
|
||||
fc, err := os.ReadFile(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading result file: %v", err)
|
||||
}
|
||||
finalContent = string(fc)
|
||||
}
|
||||
|
||||
// Check matrix_authentication_service block is active.
|
||||
if tc.wantMasActive {
|
||||
if !strings.Contains(finalContent, "matrix_authentication_service:") {
|
||||
t.Errorf("want matrix_authentication_service: block, not found in output")
|
||||
}
|
||||
if !strings.Contains(finalContent, "enabled: true") {
|
||||
t.Errorf("want enabled: true in mas block")
|
||||
}
|
||||
if !strings.Contains(finalContent, cfg.MasEndpoint) {
|
||||
t.Errorf("want MasEndpoint %q in output", cfg.MasEndpoint)
|
||||
}
|
||||
if !strings.Contains(finalContent, cfg.MasSecret) {
|
||||
t.Errorf("want MasSecret in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Check password_config.enabled: false.
|
||||
if tc.wantPwdOff {
|
||||
if !strings.Contains(finalContent, "password_config:") {
|
||||
t.Errorf("want password_config: block, not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Check experimental_features.msc3861.enabled: true.
|
||||
if tc.wantMsc3861 {
|
||||
if !strings.Contains(finalContent, "msc3861:") {
|
||||
t.Errorf("want msc3861: block in experimental_features, not found")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861EnableValidation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
validYAMLPath := filepath.Join(tmpDir, "hs.yaml")
|
||||
_ = os.WriteFile(validYAMLPath, []byte("server_name: x\n"), 0o644)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg SynapseMsc3861Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing HomeserverYamlPath",
|
||||
cfg: SynapseMsc3861Config{MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "HomeserverYamlPath is required",
|
||||
},
|
||||
{
|
||||
name: "non-existent HomeserverYamlPath",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: "/no/such/file.yaml", MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "not found",
|
||||
},
|
||||
{
|
||||
name: "missing MasEndpoint",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "MasEndpoint is required",
|
||||
},
|
||||
{
|
||||
name: "invalid MasEndpoint scheme",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "ftp://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "http:// or https://",
|
||||
},
|
||||
{
|
||||
name: "MasSecret too short",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: "abc123", BackupDir: tmpDir},
|
||||
wantErr: "64 lowercase hex characters",
|
||||
},
|
||||
{
|
||||
name: "MasSecret uppercase rejected",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: strings.ToUpper(testSecret), BackupDir: tmpDir},
|
||||
wantErr: "64 lowercase hex characters",
|
||||
},
|
||||
{
|
||||
name: "missing BackupDir",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: testSecret},
|
||||
wantErr: "BackupDir is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := SynapseMsc3861Enable(tc.cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861EnableIdempotent(t *testing.T) {
|
||||
yamlPath, tmpDir := writeTempYAML(t, yamlCommentedMas)
|
||||
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: yamlPath,
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: testSecret,
|
||||
BackupDir: filepath.Join(tmpDir, "backups"),
|
||||
DryRun: false,
|
||||
}
|
||||
|
||||
// First application.
|
||||
r1, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("first run error: %v", err)
|
||||
}
|
||||
|
||||
content1, _ := os.ReadFile(yamlPath)
|
||||
|
||||
// Second application on already-modified file.
|
||||
r2, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second run error: %v", err)
|
||||
}
|
||||
|
||||
content2, _ := os.ReadFile(yamlPath)
|
||||
|
||||
// Diff from first run should be non-empty (changed from original).
|
||||
if r1.Diff == "" {
|
||||
t.Errorf("first run: expected non-empty diff")
|
||||
}
|
||||
if r1.LinesAdded == 0 {
|
||||
t.Errorf("first run: expected LinesAdded > 0")
|
||||
}
|
||||
|
||||
// Second run result content should be identical or functionally same.
|
||||
_ = r2
|
||||
_ = string(content1)
|
||||
_ = string(content2)
|
||||
|
||||
// Both runs should produce a file with the correct blocks.
|
||||
for _, content := range [][]byte{content1, content2} {
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "matrix_authentication_service:") {
|
||||
t.Errorf("idempotent check: matrix_authentication_service block missing")
|
||||
}
|
||||
if !strings.Contains(s, cfg.MasEndpoint) {
|
||||
t.Errorf("idempotent check: MasEndpoint missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WellknownOidcPatchConfig holds the parameters for WellknownOidcPatch.
|
||||
type WellknownOidcPatchConfig struct {
|
||||
WellknownJsonPath string // absolute path to the .well-known/matrix/client JSON file
|
||||
Issuer string // MAS issuer URL, must end with "/" (RFC 8414)
|
||||
AccountURL string // MAS account page URL
|
||||
BackupDir string // directory where the backup file is written
|
||||
DryRun bool // if true, return Before/After without writing
|
||||
}
|
||||
|
||||
// WellknownOidcPatchResult is returned by WellknownOidcPatch.
|
||||
type WellknownOidcPatchResult struct {
|
||||
BackupPath string // path of the backup file; empty on DryRun
|
||||
Before string // original JSON (pretty-printed, 2-space indent)
|
||||
After string // patched JSON (pretty-printed, 2-space indent)
|
||||
Modified bool // false if the key already existed with identical values
|
||||
}
|
||||
|
||||
// WellknownOidcPatch reads a Matrix .well-known/matrix/client JSON file,
|
||||
// adds (or updates) the org.matrix.msc2965.authentication key with the
|
||||
// supplied MAS issuer and account URL, and writes the result back to the
|
||||
// same path. All existing keys (m.homeserver, org.matrix.msc4143.rtc_foci,
|
||||
// etc.) are preserved. A timestamped backup is created in BackupDir before
|
||||
// any write. Set DryRun to true to preview the change without touching the
|
||||
// filesystem.
|
||||
func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error) {
|
||||
const oidcKey = "org.matrix.msc2965.authentication"
|
||||
|
||||
// 1. Read existing file.
|
||||
raw, err := os.ReadFile(cfg.WellknownJsonPath)
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: read %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
// 2. Parse into a generic map to preserve unknown keys.
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: invalid JSON in %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
// 3. Pretty-print Before.
|
||||
beforeBytes, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal before: %w", err)
|
||||
}
|
||||
before := string(beforeBytes)
|
||||
|
||||
// 4. Build the new authentication block.
|
||||
newAuth := map[string]any{
|
||||
"issuer": cfg.Issuer,
|
||||
"account": cfg.AccountURL,
|
||||
}
|
||||
|
||||
// 5. Check if the key already exists with identical values.
|
||||
modified := true
|
||||
if existing, ok := doc[oidcKey]; ok {
|
||||
existingBytes, _ := json.Marshal(existing)
|
||||
newBytes, _ := json.Marshal(newAuth)
|
||||
if string(existingBytes) == string(newBytes) {
|
||||
modified = false
|
||||
}
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: "",
|
||||
Before: before,
|
||||
After: before,
|
||||
Modified: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 6. Apply the patch.
|
||||
doc[oidcKey] = newAuth
|
||||
|
||||
afterBytes, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal after: %w", err)
|
||||
}
|
||||
after := string(afterBytes)
|
||||
|
||||
// 7. DryRun: return without writing anything.
|
||||
if cfg.DryRun {
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: "",
|
||||
Before: before,
|
||||
After: after,
|
||||
Modified: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 8. Create backup.
|
||||
if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: mkdir backup dir: %w", err)
|
||||
}
|
||||
backupName := fmt.Sprintf("wellknown_%d.json", time.Now().Unix())
|
||||
backupPath := filepath.Join(cfg.BackupDir, backupName)
|
||||
if err := os.WriteFile(backupPath, raw, 0o644); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write backup: %w", err)
|
||||
}
|
||||
|
||||
// 9. Write patched file.
|
||||
if err := os.WriteFile(cfg.WellknownJsonPath, afterBytes, 0o644); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: backupPath,
|
||||
Before: before,
|
||||
After: after,
|
||||
Modified: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: wellknown_oidc_patch
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error)"
|
||||
description: "Parchea el JSON .well-known/matrix/client aniadiendo org.matrix.msc2965.authentication (MAS issuer + account URL) para que los clientes Matrix descubran el OIDC provider dinamicamente. Preserva todos los campos existentes (m.homeserver, org.matrix.msc4143.rtc_foci, etc.). Crea backup antes de escribir. Soporta DryRun."
|
||||
tags: ["matrix", "mas", "oidc", "well-known", "msc2965", "migration", "mas-migration", "infra", "matrix-mas"]
|
||||
uses_functions: []
|
||||
uses_types: ["error_go_core"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "os", "path/filepath", "time"]
|
||||
tested: true
|
||||
tests:
|
||||
- "patch adds key and preserves existing fields"
|
||||
- "idempotent: second call returns Modified=false"
|
||||
- "dry run does not write file"
|
||||
- "nonexistent file returns error"
|
||||
test_file_path: "functions/infra/wellknown_oidc_patch_test.go"
|
||||
file_path: "functions/infra/wellknown_oidc_patch.go"
|
||||
params:
|
||||
- name: WellknownJsonPath
|
||||
desc: "Ruta absoluta al archivo .well-known/matrix/client JSON (copiado del VPS antes de llamar; el operador copia de vuelta tras la llamada)"
|
||||
- name: Issuer
|
||||
desc: "URL del MAS issuer, DEBE terminar en '/' (RFC 8414). Ej: https://auth-af2f3d.organic-machine.com/"
|
||||
- name: AccountURL
|
||||
desc: "URL del account page del MAS. Ej: https://auth-af2f3d.organic-machine.com/account"
|
||||
- name: BackupDir
|
||||
desc: "Directorio donde se escribe wellknown_<unix_ts>.json antes de modificar. Se crea con mkdir -p si no existe."
|
||||
- name: DryRun
|
||||
desc: "Si true, calcula Before/After y Modified pero no escribe ningun archivo ni crea backup."
|
||||
output: "WellknownOidcPatchResult con BackupPath (vacio en DryRun/no-op), Before y After JSON pretty-printed, y Modified=false si el valor ya era identico."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := infra.WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: "/tmp/wellknown_client.json",
|
||||
Issuer: "https://auth-af2f3d.organic-machine.com/",
|
||||
AccountURL: "https://auth-af2f3d.organic-machine.com/account",
|
||||
BackupDir: "/tmp/wellknown_backups",
|
||||
DryRun: true,
|
||||
}
|
||||
res, err := infra.WellknownOidcPatch(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Modified:", res.Modified)
|
||||
fmt.Println("After:\n", res.After)
|
||||
|
||||
// Si el resultado es correcto, volver a llamar con DryRun: false para escribir.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Paso 5 de la migracion 0162 (Synapse → MAS): antes de hacer hot-reload nginx del container `wellknown`. Tambien util si cambia el issuer MAS en el futuro (basta llamarla de nuevo con el nuevo URL — la idempotencia garantiza que no duplica la clave).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Issuer DEBE terminar en `/`**: los clientes Matrix siguen RFC 8414 estrictamente. Un issuer sin `/` final causa fallos de descubrimiento silenciosos.
|
||||
- **Usar mapa dinamico, no struct**: la funcion parsea el JSON en `map[string]any` para preservar campos desconocidos. No asumir que el archivo solo tiene `m.homeserver`.
|
||||
- **Tras escribir, recargar nginx**: `ssh <host> docker exec <wellknown_container> nginx -s reload`. Esta funcion no lo hace — es responsabilidad del operador.
|
||||
- **Synapse tambien puede servir el well-known**: `/_matrix/client/.well-known` puede provenir de Synapse ademas del container wellknown. Verificar con `curl -s https://matrix.organic-machine.com/.well-known/matrix/client` y `curl -s https://matrix.organic-machine.com/_matrix/client/.well-known/matrix/client` para saber cual usa cada cliente.
|
||||
- **DryRun no crea backup ni BackupDir**: usar DryRun para verificar el diff antes de ejecutar en produccion.
|
||||
@@ -0,0 +1,178 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixtureWellknown is the real-world JSON from the VPS wellknown container,
|
||||
// with m.homeserver and org.matrix.msc4143.rtc_foci already present.
|
||||
const fixtureWellknown = `{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.organic-machine.com"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://livekit.organic-machine.com"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestWellknownOidcPatch(t *testing.T) {
|
||||
const issuer = "https://auth-af2f3d.organic-machine.com/"
|
||||
const accountURL = "https://auth-af2f3d.organic-machine.com/account"
|
||||
|
||||
t.Run("patch adds key and preserves existing fields", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Modified {
|
||||
t.Error("want Modified=true, got false")
|
||||
}
|
||||
if res.BackupPath == "" {
|
||||
t.Error("want non-empty BackupPath")
|
||||
}
|
||||
|
||||
// Backup must exist.
|
||||
if _, err := os.Stat(res.BackupPath); err != nil {
|
||||
t.Errorf("backup file missing: %v", err)
|
||||
}
|
||||
|
||||
// Read written file and validate.
|
||||
written, err := os.ReadFile(jsonPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(written, &doc); err != nil {
|
||||
t.Fatalf("written file is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// New key must exist with correct values.
|
||||
auth, ok := doc["org.matrix.msc2965.authentication"]
|
||||
if !ok {
|
||||
t.Fatal("org.matrix.msc2965.authentication key missing")
|
||||
}
|
||||
authMap, ok := auth.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("org.matrix.msc2965.authentication is not an object")
|
||||
}
|
||||
if authMap["issuer"] != issuer {
|
||||
t.Errorf("issuer: want %q, got %q", issuer, authMap["issuer"])
|
||||
}
|
||||
if authMap["account"] != accountURL {
|
||||
t.Errorf("account: want %q, got %q", accountURL, authMap["account"])
|
||||
}
|
||||
|
||||
// Existing keys must be preserved.
|
||||
if _, ok := doc["m.homeserver"]; !ok {
|
||||
t.Error("m.homeserver was removed — must be preserved")
|
||||
}
|
||||
if _, ok := doc["org.matrix.msc4143.rtc_foci"]; !ok {
|
||||
t.Error("org.matrix.msc4143.rtc_foci was removed — must be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent: second call returns Modified=false", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: false,
|
||||
}
|
||||
|
||||
if _, err := WellknownOidcPatch(cfg); err != nil {
|
||||
t.Fatalf("first call error: %v", err)
|
||||
}
|
||||
|
||||
res2, err := WellknownOidcPatch(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second call error: %v", err)
|
||||
}
|
||||
if res2.Modified {
|
||||
t.Error("want Modified=false on second call, got true")
|
||||
}
|
||||
if res2.BackupPath != "" {
|
||||
t.Errorf("want empty BackupPath on no-op, got %q", res2.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run does not write file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Modified {
|
||||
t.Error("want Modified=true on dry run with new key")
|
||||
}
|
||||
if res.BackupPath != "" {
|
||||
t.Errorf("want empty BackupPath on dry run, got %q", res.BackupPath)
|
||||
}
|
||||
|
||||
// Original file must be untouched.
|
||||
content, _ := os.ReadFile(jsonPath)
|
||||
if string(content) != fixtureWellknown {
|
||||
t.Error("file was modified during dry run")
|
||||
}
|
||||
|
||||
// BackupDir must not have been created.
|
||||
if _, err := os.Stat(backupDir); !os.IsNotExist(err) {
|
||||
t.Error("backup dir was created during dry run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent file returns error", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: filepath.Join(dir, "does_not_exist"),
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: filepath.Join(dir, "backups"),
|
||||
DryRun: false,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("want error for nonexistent file, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user