mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-25 22:22:16 +00:00
- Change rust-version from "1.94" to "1.95" in workspace Cargo.toml - Add validateURL method to ES2Client with private IP blocking and domain whitelisting - Add SSRF validation to DownloadProfile and GetProfileStatus endpoints - Add slices, errors, net, and url imports to es2/client.go - Update CLI charging API endpoints from /v1 to /api/v1 prefix - Fix DeductCredit response handling to use nil
372 lines
9.7 KiB
Go
372 lines
9.7 KiB
Go
package es2
|
|
|
|
import (
|
|
"slices"
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config"
|
|
)
|
|
|
|
type ES2Client struct {
|
|
httpClient *http.Client
|
|
config *config.ES2Config
|
|
baseURL string
|
|
maxRetries int
|
|
retryDelay time.Duration
|
|
}
|
|
|
|
func NewES2Client(cfg *config.ES2Config) *ES2Client {
|
|
return &ES2Client{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
|
},
|
|
},
|
|
},
|
|
config: cfg,
|
|
baseURL: cfg.BaseURL,
|
|
maxRetries: 3,
|
|
retryDelay: 1 * time.Second,
|
|
}
|
|
}
|
|
|
|
// validateURL prevents SSRF by checking if URL is safe to access
|
|
func (c *ES2Client) validateURL(urlStr string) error {
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
host := u.Hostname()
|
|
ip := net.ParseIP(host)
|
|
|
|
// Block private IPs, loopback, and link-local addresses
|
|
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
|
|
return errors.New("access to private IP addresses is not allowed")
|
|
}
|
|
|
|
// Whitelist allowed ES2/SMDP domains (should be production SM-DP+ servers)
|
|
allowedDomains := []string{"smdp.example.com", "es2plus.example.com", "smdp.carrier.com"}
|
|
allowed := slices.Contains(allowedDomains, host)
|
|
|
|
if !allowed {
|
|
return fmt.Errorf("domain %s is not in allowed ES2 domains list", host)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retryableError checks if an error is retryable
|
|
func (c *ES2Client) retryableError(err error, statusCode int) bool {
|
|
if err != nil {
|
|
return true // Network errors are retryable
|
|
}
|
|
// Retry on 5xx server errors and 429 rate limit
|
|
return statusCode >= 500 || statusCode == 429
|
|
}
|
|
|
|
// executeWithRetry executes an HTTP request with retry logic
|
|
func (c *ES2Client) executeWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
|
var lastErr error
|
|
var lastResp *http.Response
|
|
|
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
// Exponential backoff
|
|
backoff := c.retryDelay * time.Duration(math.Pow(2, float64(attempt-1)))
|
|
select {
|
|
case <-time.After(backoff):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
if !c.retryableError(err, 0) {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
return resp, nil
|
|
}
|
|
|
|
// Save response body for potential error reporting
|
|
lastResp = resp
|
|
|
|
if !c.retryableError(nil, resp.StatusCode) {
|
|
return resp, nil
|
|
}
|
|
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
if lastResp != nil {
|
|
return lastResp, lastErr
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileRequest) (*DownloadProfileResponse, error) {
|
|
url := fmt.Sprintf("%s/es2plus/downloadProfile", c.baseURL)
|
|
|
|
// Validate URL to prevent SSRF
|
|
if err := c.validateURL(url); err != nil {
|
|
return nil, fmt.Errorf("SSRF protection: %w", err)
|
|
}
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.executeWithRetry(ctx, httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request after retries: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var downloadResp DownloadProfileResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&downloadResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &downloadResp, nil
|
|
}
|
|
|
|
func (c *ES2Client) GetProfileStatus(ctx context.Context, req *GetProfileStatusRequest) (*GetProfileStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/es2plus/getProfileStatus", c.baseURL)
|
|
|
|
// Validate URL to prevent SSRF
|
|
if err := c.validateURL(url); err != nil {
|
|
return nil, fmt.Errorf("SSRF protection: %w", err)
|
|
}
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.executeWithRetry(ctx, httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request after retries: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var statusResp GetProfileStatusResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &statusResp, nil
|
|
}
|
|
|
|
func (c *ES2Client) DeleteProfile(ctx context.Context, req *DeleteProfileRequest) (*DeleteProfileResponse, error) {
|
|
url := fmt.Sprintf("%s/es2plus/deleteProfile", c.baseURL)
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.executeWithRetry(ctx, httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request after retries: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var deleteResp DeleteProfileResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&deleteResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &deleteResp, nil
|
|
}
|
|
|
|
func (c *ES2Client) EnableProfile(ctx context.Context, req *EnableProfileRequest) (*EnableProfileResponse, error) {
|
|
url := fmt.Sprintf("%s/es2plus/enableProfile", c.baseURL)
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.executeWithRetry(ctx, httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request after retries: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var enableResp EnableProfileResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&enableResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &enableResp, nil
|
|
}
|
|
|
|
func (c *ES2Client) DisableProfile(ctx context.Context, req *DisableProfileRequest) (*DisableProfileResponse, error) {
|
|
url := fmt.Sprintf("%s/es2plus/disableProfile", c.baseURL)
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.executeWithRetry(ctx, httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request after retries: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var disableResp DisableProfileResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&disableResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &disableResp, nil
|
|
}
|
|
|
|
func (c *ES2Client) setHeaders(req *http.Request) {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "Telecom-Platform/1.0")
|
|
|
|
if c.config.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
|
}
|
|
|
|
if c.config.FunctionalityRequesterID != "" {
|
|
req.Header.Set("X-Admin-Protocol", "gsma-rsp")
|
|
req.Header.Set("X-Request-ID", generateRequestID())
|
|
}
|
|
}
|
|
|
|
func generateRequestID() string {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
|
|
type DownloadProfileRequest struct {
|
|
EID string `json:"eid"`
|
|
ICCID string `json:"iccid"`
|
|
ProfileType string `json:"profileType"`
|
|
ConfirmationCode string `json:"confirmationCode,omitempty"`
|
|
}
|
|
|
|
type DownloadProfileResponse struct {
|
|
ExecutionStatus string `json:"executionStatus"`
|
|
StatusMessage string `json:"statusMessage"`
|
|
}
|
|
|
|
type GetProfileStatusRequest struct {
|
|
EID string `json:"eid"`
|
|
ICCID string `json:"iccid"`
|
|
}
|
|
|
|
type GetProfileStatusResponse struct {
|
|
ExecutionStatus string `json:"executionStatus"`
|
|
StatusMessage string `json:"statusMessage"`
|
|
ProfileState string `json:"profileState,omitempty"`
|
|
}
|
|
|
|
type DeleteProfileRequest struct {
|
|
EID string `json:"eid"`
|
|
ICCID string `json:"iccid"`
|
|
}
|
|
|
|
type DeleteProfileResponse struct {
|
|
ExecutionStatus string `json:"executionStatus"`
|
|
StatusMessage string `json:"statusMessage"`
|
|
}
|
|
|
|
type EnableProfileRequest struct {
|
|
EID string `json:"eid"`
|
|
ICCID string `json:"iccid"`
|
|
}
|
|
|
|
type EnableProfileResponse struct {
|
|
ExecutionStatus string `json:"executionStatus"`
|
|
StatusMessage string `json:"statusMessage"`
|
|
}
|
|
|
|
type DisableProfileRequest struct {
|
|
EID string `json:"eid"`
|
|
ICCID string `json:"iccid"`
|
|
}
|
|
|
|
type DisableProfileResponse struct {
|
|
ExecutionStatus string `json:"executionStatus"`
|
|
StatusMessage string `json:"statusMessage"`
|
|
}
|