feat: scaffold matrix_admin_panel v0.1.0 (issue 0163)
Wails + React + Mantine v7 admin panel for Matrix/Synapse. Replaces the removed synapse-admin container. MAS OIDC PKCE login (loopback :8766) + Synapse Admin API (users/rooms/sessions). - MAS client: XSFD2SWA394DXRVJFTREAMY6J6 (public PKCE, no auth method). - Backend: AdminService (Go) with Login/SetAdminToken/ListUsers/ DeactivateUser/ResetUserPassword/ListRooms/DeleteRoom/GetUserDevices. - Vendored helpers in internal/infra/ from registry: mas_oidc_loopback_go_infra, keyring_token_store_go_infra, synapse_admin_client_go_infra. - Frontend: AppShell + sidebar tabs (Users/Rooms/Sessions). Sessions placeholder pending MAS admin API. - Build verified: Linux + Windows.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Load when no token exists for the given account.
|
||||
var ErrNotFound = errors.New("token not found in keyring")
|
||||
|
||||
// Token holds OAuth/OIDC credentials that need to survive app restarts.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never expires
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
HomeserverURL string `json:"homeserver_url"`
|
||||
Issuer string `json:"issuer,omitempty"` // MAS/OIDC issuer URL
|
||||
ClientID string `json:"client_id,omitempty"` // MAS client_id used
|
||||
}
|
||||
|
||||
// KeyringTokenStore persists tokens in the OS keyring (Secret Service on Linux,
|
||||
// Keychain on macOS, Credential Manager on Windows).
|
||||
type KeyringTokenStore struct {
|
||||
// Service is the keyring namespace. Keep it stable across app versions.
|
||||
// Example: "fn_registry.matrix_client_pc"
|
||||
Service string
|
||||
}
|
||||
|
||||
// NewKeyringTokenStore returns a store scoped to the given service name.
|
||||
func NewKeyringTokenStore(service string) *KeyringTokenStore {
|
||||
return &KeyringTokenStore{Service: service}
|
||||
}
|
||||
|
||||
// Save serialises t to JSON and writes it to the keyring under (service, account).
|
||||
// Overwrites silently if an entry already exists.
|
||||
// account is typically the user ID, e.g. "@user:homeserver.example.com".
|
||||
func (s *KeyringTokenStore) Save(account string, t Token) error {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring save: marshal: %w", err)
|
||||
}
|
||||
if err := keyring.Set(s.Service, account, string(b)); err != nil {
|
||||
return fmt.Errorf("keyring save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load retrieves and deserialises the token stored under (service, account).
|
||||
// Returns ErrNotFound if no entry exists. Callers should check with errors.Is.
|
||||
func (s *KeyringTokenStore) Load(account string) (*Token, error) {
|
||||
raw, err := keyring.Get(s.Service, account)
|
||||
if err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("keyring load: %w", err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||
return nil, fmt.Errorf("keyring load: unmarshal: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for account from the keyring.
|
||||
// Idempotent: if no entry exists, returns nil.
|
||||
func (s *KeyringTokenStore) Delete(account string) error {
|
||||
err := keyring.Delete(s.Service, account)
|
||||
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
|
||||
return fmt.Errorf("keyring delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MasOidcLoopbackConfig configura el flujo OAuth2 PKCE con loopback HTTP
|
||||
// contra Matrix Authentication Service (MAS).
|
||||
type MasOidcLoopbackConfig struct {
|
||||
// Issuer es la URL base del MAS. Debe terminar en "/".
|
||||
// La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir endpoints.
|
||||
Issuer string
|
||||
|
||||
// ClientID es el ULID del client registrado en MAS.
|
||||
// El client debe tener client_auth_method: none (public client PKCE).
|
||||
ClientID string
|
||||
|
||||
// Scopes a solicitar. Si vacio usa ["openid", "urn:matrix:org.matrix.msc2967.client:api:*"].
|
||||
Scopes []string
|
||||
|
||||
// LoopbackPort es el puerto local donde escucha el callback.
|
||||
// Debe coincidir con el redirect_uri registrado en MAS (http://127.0.0.1:{port}/callback).
|
||||
// Si 0, elige un puerto libre dinamicamente.
|
||||
LoopbackPort int
|
||||
|
||||
// OpenBrowser abre el browser del SO automaticamente si es true.
|
||||
// Si false, imprime la URL a stdout y espera que el caller la abra.
|
||||
OpenBrowser bool
|
||||
|
||||
// TimeoutSeconds es el tiempo maximo esperando el callback. Default 300.
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// MasOidcLoopbackResult contiene los tokens devueltos por MAS tras el intercambio.
|
||||
type MasOidcLoopbackResult struct {
|
||||
// AccessToken es el Bearer token para usar contra Synapse.
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
// RefreshToken permite renovar el access token sin re-autenticar.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
|
||||
// ExpiresIn es el tiempo de vida del access token en segundos.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
|
||||
// TokenType es el tipo de token, normalmente "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
|
||||
// Scope es la lista de scopes concedidos (space-separated).
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// IDToken es el JWT de identidad OIDC (puede estar vacio si no se pidio openid).
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
// oidcDiscovery es la respuesta de .well-known/openid-configuration.
|
||||
type oidcDiscovery struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
// MasOidcLoopback ejecuta el flujo OAuth2 Authorization Code + PKCE contra MAS
|
||||
// usando un servidor HTTP loopback para recibir el callback.
|
||||
//
|
||||
// Flujo:
|
||||
// 1. Discovery de endpoints via .well-known/openid-configuration.
|
||||
// 2. Generacion de code_verifier/challenge PKCE y state anti-CSRF.
|
||||
// 3. Arranque de servidor loopback en 127.0.0.1:{LoopbackPort}.
|
||||
// 4. Apertura del browser (o impresion de URL si OpenBrowser=false).
|
||||
// 5. Espera del callback con el authorization code.
|
||||
// 6. Intercambio del code por tokens via POST al token_endpoint.
|
||||
// 7. Devolucion de MasOidcLoopbackResult.
|
||||
func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error) {
|
||||
// 1. Validar inputs
|
||||
if cfg.Issuer == "" {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: Issuer no puede estar vacio")
|
||||
}
|
||||
if !strings.HasSuffix(cfg.Issuer, "/") {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: Issuer debe terminar en '/' (got %q)", cfg.Issuer)
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: ClientID no puede estar vacio")
|
||||
}
|
||||
if cfg.LoopbackPort < 0 {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: LoopbackPort debe ser >= 0")
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
|
||||
scopes := cfg.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"}
|
||||
}
|
||||
|
||||
// 2. Discovery OIDC
|
||||
discovery, err := masOidcDiscover(cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: discovery failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. PKCE: code_verifier + code_challenge
|
||||
verifier, challenge, err := masOidcPKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: pkce generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. State anti-CSRF
|
||||
state, err := masOidcRandomBase64URL(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: state generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Arrancar loopback server
|
||||
listener, port, err := masOidcStartListener(cfg.LoopbackPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: no se pudo abrir puerto loopback: %w", err)
|
||||
}
|
||||
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
|
||||
|
||||
// Canal para recibir el code o error desde el handler HTTP
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
// Validar state anti-CSRF
|
||||
if q.Get("state") != state {
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: state mismatch (posible CSRF) — esperado %q, recibido %q", state, q.Get("state"))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("<html><body><h2>Error: state mismatch. Por favor cierra esta ventana.</h2></body></html>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar error del proveedor
|
||||
if errParam := q.Get("error"); errParam != "" {
|
||||
desc := q.Get("error_description")
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: proveedor devolvio error %q: %s", errParam, desc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf("<html><body><h2>Error de autorizacion: %s</h2></body></html>", desc)))
|
||||
return
|
||||
}
|
||||
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: callback sin 'code'")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("<html><body><h2>Error: no se recibio authorization code.</h2></body></html>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Responder al browser con mensaje de exito
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head><meta charset="utf-8"><title>Login completo</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;padding:3em;">
|
||||
<h2>Login completo</h2>
|
||||
<p>Puedes cerrar esta ventana y volver a la aplicacion.</p>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
codeCh <- code
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
||||
// Arrancar el servidor en goroutine
|
||||
srvErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
srvErrCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// 6. Construir URL de autorización
|
||||
authURL := masOidcBuildAuthURL(
|
||||
discovery.AuthorizationEndpoint,
|
||||
cfg.ClientID,
|
||||
redirectURI,
|
||||
strings.Join(scopes, " "),
|
||||
state,
|
||||
challenge,
|
||||
)
|
||||
|
||||
// 7. Abrir browser o imprimir URL
|
||||
if cfg.OpenBrowser {
|
||||
if err := masOidcOpenBrowser(authURL); err != nil {
|
||||
// No es fatal: continuamos y el usuario puede abrir manualmente
|
||||
fmt.Printf("mas_oidc_loopback: no se pudo abrir el browser automaticamente.\nAbre esta URL manualmente:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Abre esta URL en tu browser para autenticarte:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
// 8. Esperar callback con timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var code string
|
||||
select {
|
||||
case code = <-codeCh:
|
||||
// ok
|
||||
case callbackErr := <-errCh:
|
||||
_ = srv.Shutdown(context.Background())
|
||||
return nil, callbackErr
|
||||
case <-ctx.Done():
|
||||
_ = srv.Shutdown(context.Background())
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: timeout esperando callback despues de %v", timeout)
|
||||
case srvErr := <-srvErrCh:
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: servidor loopback fallo: %w", srvErr)
|
||||
}
|
||||
|
||||
// 9. Shutdown graceful del servidor loopback
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
|
||||
// 10. Intercambiar code por tokens
|
||||
result, err := masOidcExchangeCode(
|
||||
discovery.TokenEndpoint,
|
||||
cfg.ClientID,
|
||||
code,
|
||||
redirectURI,
|
||||
verifier,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: token exchange failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// masOidcHTTPClient es el cliente HTTP usado por masOidcDiscover y masOidcExchangeCode.
|
||||
// Tiene timeout de 15s. Puede ser reemplazado en tests.
|
||||
var masOidcHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// masOidcDiscover obtiene los endpoints OIDC desde .well-known/openid-configuration.
|
||||
func masOidcDiscover(issuer string) (*oidcDiscovery, error) {
|
||||
discoveryURL := issuer + ".well-known/openid-configuration"
|
||||
resp, err := masOidcHTTPClient.Get(discoveryURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", discoveryURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("discovery HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var d oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("parsing discovery JSON: %w", err)
|
||||
}
|
||||
if d.AuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("discovery: authorization_endpoint vacio")
|
||||
}
|
||||
if d.TokenEndpoint == "" {
|
||||
return nil, fmt.Errorf("discovery: token_endpoint vacio")
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// masOidcPKCE genera un code_verifier aleatorio y su code_challenge SHA256/base64url.
|
||||
func masOidcPKCE() (verifier, challenge string, err error) {
|
||||
verifier, err = masOidcRandomBase64URL(32) // 32 bytes -> 43 chars base64url
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
// masOidcRandomBase64URL genera n bytes aleatorios codificados en base64url sin padding.
|
||||
func masOidcRandomBase64URL(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// masOidcStartListener abre un listener TCP en 127.0.0.1:{port}.
|
||||
// Si port=0, elige un puerto libre y devuelve el puerto asignado.
|
||||
func masOidcStartListener(port int) (net.Listener, int, error) {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
l, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
assignedPort := l.Addr().(*net.TCPAddr).Port
|
||||
return l, assignedPort, nil
|
||||
}
|
||||
|
||||
// masOidcBuildAuthURL construye la URL de autorización OAuth2 con PKCE.
|
||||
func masOidcBuildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge string) string {
|
||||
u, _ := url.Parse(authEndpoint)
|
||||
q := u.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", clientID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("scope", scope)
|
||||
q.Set("state", state)
|
||||
q.Set("code_challenge", challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// masOidcOpenBrowser abre la URL en el browser predeterminado del SO.
|
||||
func masOidcOpenBrowser(rawURL string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", rawURL)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return fmt.Errorf("plataforma no soportada para abrir browser: %s", runtime.GOOS)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// masOidcExchangeCode intercambia el authorization code por tokens via POST al token_endpoint.
|
||||
func masOidcExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (*MasOidcLoopbackResult, error) {
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code_verifier", verifier)
|
||||
|
||||
resp, err := masOidcHTTPClient.PostForm(tokenEndpoint, formData) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("POST %s: %w", tokenEndpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("leyendo respuesta del token endpoint: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token endpoint HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result MasOidcLoopbackResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parsing token response JSON: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("token response sin access_token: %s", string(body))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SynapseAdminClient wraps the Synapse Admin API (/_synapse/admin/...) for user and room management.
|
||||
type SynapseAdminClient struct {
|
||||
HomeserverURL string // e.g. https://matrix-af2f3d.organic-machine.com
|
||||
AdminToken string // access_token of a user with admin:true in Synapse
|
||||
HTTPClient *http.Client // optional; default 30s timeout
|
||||
}
|
||||
|
||||
// NewSynapseAdminClient creates a client with sensible defaults.
|
||||
func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient {
|
||||
return &SynapseAdminClient{
|
||||
HomeserverURL: homeserver,
|
||||
AdminToken: adminToken,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUser represents a Synapse user as returned by the admin API.
|
||||
type AdminUser struct {
|
||||
UserID string `json:"name"`
|
||||
DisplayName string `json:"displayname"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Admin bool `json:"admin"`
|
||||
Deactivated bool `json:"deactivated"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
CreationTs int64 `json:"creation_ts"`
|
||||
LastSeenTs int64 `json:"last_seen_ts"`
|
||||
}
|
||||
|
||||
// ListUsersFilter controls pagination and filtering for ListUsers.
|
||||
type ListUsersFilter struct {
|
||||
From int // pagination offset
|
||||
Limit int // default 100
|
||||
SearchTerm string // filter by name / user_id
|
||||
Deactivated *bool // nil = both, true/false to filter
|
||||
Admins *bool // nil = both, true/false to filter
|
||||
}
|
||||
|
||||
// ListUsersResult holds a page of users plus pagination metadata.
|
||||
type ListUsersResult struct {
|
||||
Users []AdminUser
|
||||
TotalCount int
|
||||
NextToken *int // nil if last page
|
||||
}
|
||||
|
||||
// AdminRoom represents a Synapse room as returned by the admin API.
|
||||
type AdminRoom struct {
|
||||
RoomID string `json:"room_id"`
|
||||
Name string `json:"name"`
|
||||
CanonicalAlias string `json:"canonical_alias"`
|
||||
JoinedMembers int `json:"joined_members"`
|
||||
JoinedLocal int `json:"joined_local_members"`
|
||||
Version string `json:"version"`
|
||||
Encrypted bool `json:"encryption_enabled"`
|
||||
Federatable bool `json:"federatable"`
|
||||
Public bool `json:"public"`
|
||||
}
|
||||
|
||||
// AdminDevice represents a device belonging to a Synapse user.
|
||||
type AdminDevice struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastSeenIP string `json:"last_seen_ip"`
|
||||
LastSeenTs int64 `json:"last_seen_ts"`
|
||||
}
|
||||
|
||||
// synapseError is the error envelope returned by Synapse for 4xx/5xx responses.
|
||||
type synapseError struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
ErrMsg string `json:"error"`
|
||||
}
|
||||
|
||||
// client returns the HTTPClient, falling back to a 30-second default.
|
||||
func (c *SynapseAdminClient) client() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// do executes an authenticated request and returns the raw response body.
|
||||
// Returns an error for HTTP >= 400, including the Synapse errcode when present.
|
||||
func (c *SynapseAdminClient) do(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.HomeserverURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.AdminToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: http %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
var se synapseError
|
||||
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
|
||||
return nil, fmt.Errorf("synapse_admin: synapse internal %d %s: %s", resp.StatusCode, se.ErrCode, se.ErrMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("synapse_admin: synapse internal: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var se synapseError
|
||||
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
|
||||
return nil, fmt.Errorf("synapse_admin: %s %s → %d %s: %s", method, path, resp.StatusCode, se.ErrCode, se.ErrMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("synapse_admin: %s %s → HTTP %d", method, path, resp.StatusCode)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
// ListUsers returns a page of users matching the given filter.
|
||||
// Use ListUsersResult.NextToken to paginate: set ListUsersFilter.From = *NextToken on the next call.
|
||||
func (c *SynapseAdminClient) ListUsers(ctx context.Context, f ListUsersFilter) (*ListUsersResult, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("from", strconv.Itoa(f.From))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
if f.SearchTerm != "" {
|
||||
q.Set("user_id", f.SearchTerm)
|
||||
}
|
||||
if f.Deactivated != nil {
|
||||
q.Set("deactivated", strconv.FormatBool(*f.Deactivated))
|
||||
}
|
||||
if f.Admins != nil {
|
||||
q.Set("admins", strconv.FormatBool(*f.Admins))
|
||||
}
|
||||
|
||||
path := "/_synapse/admin/v2/users?" + q.Encode()
|
||||
data, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Users []AdminUser `json:"users"`
|
||||
Total int `json:"total"`
|
||||
NextToken *int `json:"next_token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: ListUsers decode: %w", err)
|
||||
}
|
||||
return &ListUsersResult{
|
||||
Users: raw.Users,
|
||||
TotalCount: raw.Total,
|
||||
NextToken: raw.NextToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUser returns the admin view of a single user by their full Matrix ID (e.g. @user:server).
|
||||
func (c *SynapseAdminClient) GetUser(ctx context.Context, userID string) (*AdminUser, error) {
|
||||
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID)
|
||||
data, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var u AdminUser
|
||||
if err := json.Unmarshal(data, &u); err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: GetUser decode: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// DeactivateUser deactivates a user account.
|
||||
// If erase=true, Synapse purges all user data — IRREVERSIBLE.
|
||||
func (c *SynapseAdminClient) DeactivateUser(ctx context.Context, userID string, erase bool) error {
|
||||
path := "/_synapse/admin/v1/deactivate/" + url.PathEscape(userID)
|
||||
_, err := c.do(ctx, http.MethodPost, path, map[string]bool{"erase": erase})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password for the given user.
|
||||
// If logoutDevices=true, all existing sessions are invalidated.
|
||||
func (c *SynapseAdminClient) ResetPassword(ctx context.Context, userID, newPassword string, logoutDevices bool) error {
|
||||
path := "/_synapse/admin/v1/reset_password/" + url.PathEscape(userID)
|
||||
body := map[string]interface{}{
|
||||
"new_password": newPassword,
|
||||
"logout_devices": logoutDevices,
|
||||
}
|
||||
_, err := c.do(ctx, http.MethodPost, path, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Rooms ---
|
||||
|
||||
// ListRooms returns a page of rooms.
|
||||
// from and limit control pagination; searchTerm filters by room name/alias.
|
||||
func (c *SynapseAdminClient) ListRooms(ctx context.Context, from, limit int, searchTerm string) (rooms []AdminRoom, total int, nextToken *int, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("from", strconv.Itoa(from))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
q.Set("order_by", "name")
|
||||
if searchTerm != "" {
|
||||
q.Set("search_term", searchTerm)
|
||||
}
|
||||
|
||||
path := "/_synapse/admin/v1/rooms?" + q.Encode()
|
||||
data, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Rooms []AdminRoom `json:"rooms"`
|
||||
TotalRooms int `json:"total_rooms"`
|
||||
NextBatch *int `json:"next_batch"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("synapse_admin: ListRooms decode: %w", err)
|
||||
}
|
||||
return raw.Rooms, raw.TotalRooms, raw.NextBatch, nil
|
||||
}
|
||||
|
||||
// GetRoom returns the admin view of a single room by its room ID (e.g. !room:server).
|
||||
func (c *SynapseAdminClient) GetRoom(ctx context.Context, roomID string) (*AdminRoom, error) {
|
||||
path := "/_synapse/admin/v1/rooms/" + url.PathEscape(roomID)
|
||||
data, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var r AdminRoom
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: GetRoom decode: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// DeleteRoom schedules an async room deletion. Returns the delete_id for status polling.
|
||||
// purge=true destroys all messages and state (IRREVERSIBLE).
|
||||
// block=true prevents new users from joining after deletion.
|
||||
func (c *SynapseAdminClient) DeleteRoom(ctx context.Context, roomID, reason string, purge, block bool) (deleteID string, err error) {
|
||||
path := "/_synapse/admin/v2/rooms/" + url.PathEscape(roomID)
|
||||
body := map[string]interface{}{
|
||||
"new_room_user_id": nil,
|
||||
"purge": purge,
|
||||
"block": block,
|
||||
"message": reason,
|
||||
}
|
||||
data, err := c.do(ctx, http.MethodDelete, path, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
DeleteID string `json:"delete_id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return "", fmt.Errorf("synapse_admin: DeleteRoom decode: %w", err)
|
||||
}
|
||||
return raw.DeleteID, nil
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
// ListUserDevices returns all devices registered for the given user.
|
||||
func (c *SynapseAdminClient) ListUserDevices(ctx context.Context, userID string) ([]AdminDevice, error) {
|
||||
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices"
|
||||
data, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Devices []AdminDevice `json:"devices"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("synapse_admin: ListUserDevices decode: %w", err)
|
||||
}
|
||||
return raw.Devices, nil
|
||||
}
|
||||
|
||||
// DeleteUserDevice removes a specific device from a user's account.
|
||||
func (c *SynapseAdminClient) DeleteUserDevice(ctx context.Context, userID, deviceID string) error {
|
||||
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices/" + url.PathEscape(deviceID)
|
||||
_, err := c.do(ctx, http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user