package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) type adminInternalAPIConfig struct { Enabled bool `json:"enabled"` BaseURL string `json:"base_url"` ServiceName string `json:"service_name"` Secret string `json:"secret"` TimeoutMS int `json:"timeout_ms"` } type adminInternalClient struct { config adminInternalAPIConfig http *http.Client } type tenantLoginIPWhitelistConfig struct { TenantID types.TenantID `json:"tenant_id"` AppList []string `json:"app_list"` WebList []string `json:"web_list"` } type tenantLoginIPWhitelistProvider interface { TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) } var loginIPWhitelistProvider tenantLoginIPWhitelistProvider type adminInternalTenantDTO struct { ID types.TenantID `json:"id"` Code string `json:"code"` Name string `json:"name"` TenantDesc *string `json:"tenant_desc,omitempty"` DBName string `json:"db_name"` DBDSN string `json:"db_dsn"` State types.TenantState `json:"state"` CreatedAt time.Time `json:"created_at"` CreatedBy int64 `json:"created_by"` UpdatedAt time.Time `json:"updated_at"` UpdatedBy int64 `json:"updated_by"` } func initAdminInternalAPI(raw json.RawMessage) error { store.SetAdminInternalConfigProvider(nil) loginIPWhitelistProvider = nil if len(raw) == 0 || string(raw) == "null" { return nil } var cfg adminInternalAPIConfig if err := json.Unmarshal(raw, &cfg); err != nil { return err } cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) if cfg.ServiceName == "" { cfg.ServiceName = "im-server" } if cfg.TimeoutMS <= 0 { cfg.TimeoutMS = 3000 } if !cfg.Enabled { return nil } if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" { return errors.New("admin_internal_api enabled but base_url or secret is empty") } client := &adminInternalClient{ config: cfg, http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond}, } store.SetAdminInternalConfigProvider(client) loginIPWhitelistProvider = client logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName) return nil } func (c *adminInternalClient) GetTenantByCode(code string) (*types.Tenant, error) { var tenant adminInternalTenantDTO if err := c.getJSON("/internal/im-config/tenants/by-code/"+url.PathEscape(strings.TrimSpace(code)), nil, &tenant); err != nil { return nil, err } return tenant.toStoreTenant(), nil } func (c *adminInternalClient) GetTenantByID(id types.TenantID) (*types.Tenant, error) { if !id.IsValid() { return nil, types.ErrMalformed } var tenant adminInternalTenantDTO if err := c.getJSON("/internal/im-config/tenants/by-id/"+strconv.FormatInt(int64(id), 10), nil, &tenant); err != nil { return nil, err } return tenant.toStoreTenant(), nil } func (c *adminInternalClient) ListActiveTenants(limit int) ([]types.Tenant, error) { query := url.Values{} query.Set("state", "active") if limit > 0 { query.Set("limit", strconv.Itoa(limit)) } var wrapper struct { Items []adminInternalTenantDTO `json:"items"` } if err := c.getJSON("/internal/im-config/tenants", query, &wrapper); err != nil { return nil, err } tenants := make([]types.Tenant, 0, len(wrapper.Items)) for _, item := range wrapper.Items { if tenant := item.toStoreTenant(); tenant != nil { tenants = append(tenants, *tenant) } } return tenants, nil } func (c *adminInternalClient) RegistrationSMSEnabled() (bool, error) { var wrapper struct { Enabled bool `json:"enabled"` } if err := c.getJSON("/internal/im-config/registration/sms", nil, &wrapper); err != nil { return true, err } return wrapper.Enabled, nil } func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) { query := url.Values{} normalized := make([]string, 0, len(keys)) for _, key := range keys { if key = strings.TrimSpace(key); key != "" { normalized = append(normalized, key) } } if len(normalized) > 0 { query.Set("keys", strings.Join(normalized, ",")) } var wrapper struct { Values map[string]string `json:"values"` } if err := c.getJSON("/internal/im-config/third-party/"+url.PathEscape(strings.TrimSpace(group)), query, &wrapper); err != nil { return nil, err } if wrapper.Values == nil { wrapper.Values = map[string]string{} } return wrapper.Values, nil } func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) { if !id.IsValid() { return nil, types.ErrMalformed } var result tenantLoginIPWhitelistConfig path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/login-ip-whitelist" if err := c.getJSON(path, nil, &result); err != nil { return nil, err } result.TenantID = id if result.AppList == nil { result.AppList = []string{} } if result.WebList == nil { result.WebList = []string{} } return &result, nil } func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error { if c == nil || c.http == nil { return types.ErrUnsupported } path = "/" + strings.TrimLeft(path, "/") u, err := url.Parse(c.config.BaseURL + path) if err != nil { return err } if len(query) > 0 { u.RawQuery = query.Encode() } req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return err } if err = c.signRequest(req, nil); err != nil { return err } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("admin internal api status=%d body=%s", resp.StatusCode, string(body)) } var parsed struct { Code int `json:"code"` Data json.RawMessage `json:"data"` } if err = json.Unmarshal(body, &parsed); err != nil { return err } if parsed.Code != 0 { return fmt.Errorf("admin internal api code=%d", parsed.Code) } if out == nil || len(parsed.Data) == 0 || string(parsed.Data) == "null" { return nil } return json.Unmarshal(parsed.Data, out) } func (c *adminInternalClient) signRequest(req *http.Request, body []byte) error { nonce, err := randomInternalNonce() if err != nil { return err } timestamp := strconv.FormatInt(time.Now().Unix(), 10) bodyHash := sha256.Sum256(body) signingText := strings.Join([]string{ req.Method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce, }, "\n") req.Header.Set(internalHeaderService, c.config.ServiceName) req.Header.Set(internalHeaderTimestamp, timestamp) req.Header.Set(internalHeaderNonce, nonce) req.Header.Set(internalHeaderSignature, signInternalRequest(c.config.Secret, signingText)) req.Header.Set("Accept", "application/json") if len(body) > 0 { req.Body = io.NopCloser(bytes.NewReader(body)) req.ContentLength = int64(len(body)) req.Header.Set("Content-Type", "application/json") } return nil } func randomInternalNonce() (string, error) { var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { return "", err } return hex.EncodeToString(raw[:]), nil } func signInternalRequest(secret, signingText string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(signingText)) return hex.EncodeToString(mac.Sum(nil)) } func (t adminInternalTenantDTO) toStoreTenant() *types.Tenant { if t.ID.IsZero() && strings.TrimSpace(t.Code) == "" { return nil } return &types.Tenant{ ID: t.ID, Code: strings.TrimSpace(t.Code), Name: t.Name, TenantDesc: t.TenantDesc, DBName: t.DBName, DBDSN: t.DBDSN, State: t.State, CreatedAt: t.CreatedAt, CreatedBy: t.CreatedBy, UpdatedAt: t.UpdatedAt, UpdatedBy: t.UpdatedBy, } }