daef7ea190
Helper functions (matrix-mas capability group): - mas_client_register_bash_infra: register/sync OAuth clients via mas-cli - mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS - synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff) - wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication - synapse_login_flows_check_go_infra: health-check post-migration login flows Flows + issues for custom Matrix clients (PC + Android): - 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153) - 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161) - 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS - 0163 custom admin panel propio (sustituye synapse-admin) Production state (organic-machine.com): - Synapse migrated SQLite -> Postgres - MSC3861 active, password_config disabled - 21 users + 41 access_tokens migrated via syn2mas - 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel) - synapse-admin container removed + Coolify route deleted - well-known patched with org.matrix.msc2965.authentication Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
6.9 KiB
Go
220 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|