0e3c5f5e84
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.
324 lines
10 KiB
Go
324 lines
10 KiB
Go
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
|
|
}
|