318 lines
11 KiB
Go
318 lines
11 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"cmpp-platform/gateway/internal/inbound"
|
|
"cmpp-platform/gateway/internal/queue"
|
|
"cmpp-platform/gateway/internal/ratelimit"
|
|
"cmpp-platform/gateway/internal/upstream"
|
|
)
|
|
|
|
type ConnectFunc func(context.Context, ConnectChannelCommand) (ConnectionStateCallback, error)
|
|
type SubmitFunc func(context.Context, queue.SubmitCommand) (queue.SubmitResult, error)
|
|
|
|
type ConnectChannelCommand struct {
|
|
SchemaVersion string `json:"schemaVersion"`
|
|
MessageType string `json:"messageType"`
|
|
TraceID string `json:"traceId"`
|
|
ChannelID string `json:"channelId"`
|
|
ConnectionID string `json:"connectionId"`
|
|
Reason string `json:"reason"`
|
|
DesiredConnections int `json:"desiredConnections"`
|
|
Channel ChannelConfig `json:"channel"`
|
|
}
|
|
|
|
type ChannelConfig struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
GatewayHost string `json:"gatewayHost"`
|
|
GatewayPort int `json:"gatewayPort"`
|
|
Account string `json:"account"`
|
|
PasswordCipher string `json:"passwordCipher"`
|
|
SrcID string `json:"srcId"`
|
|
CMPPVersion string `json:"cmppVersion"`
|
|
RateLimitPerSecond int `json:"rateLimitPerSecond"`
|
|
}
|
|
|
|
type ConnectionStateCallback struct {
|
|
ChannelID string `json:"channelId"`
|
|
ConnectionID string `json:"connectionId"`
|
|
Status string `json:"status"`
|
|
DesiredConnections int `json:"desiredConnections"`
|
|
CurrentConnections int `json:"currentConnections"`
|
|
LastConnectedAt string `json:"lastConnectedAt,omitempty"`
|
|
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
|
|
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
|
|
ReconnectCount int `json:"reconnectCount,omitempty"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
}
|
|
|
|
type Server struct {
|
|
APIBaseURL string
|
|
HTTPClient *http.Client
|
|
Connect ConnectFunc
|
|
Submit SubmitFunc
|
|
Upstream *upstream.Manager
|
|
Limiter ratelimit.Limiter
|
|
RecoveryCandidates func(context.Context) ([]inbound.DownstreamPresence, error)
|
|
RecoveryStatuses func(context.Context) ([]inbound.DownstreamRecoveryStatus, error)
|
|
}
|
|
|
|
type DownstreamRecoveryOverview struct {
|
|
Candidates []inbound.DownstreamPresence `json:"candidates"`
|
|
Statuses []inbound.DownstreamRecoveryStatus `json:"statuses"`
|
|
}
|
|
|
|
func Register(mux *http.ServeMux, server Server) {
|
|
if server.HTTPClient == nil {
|
|
server.HTTPClient = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
if server.Upstream == nil {
|
|
server.Upstream = &upstream.Manager{APIBaseURL: server.APIBaseURL, HTTPClient: server.HTTPClient}
|
|
}
|
|
if server.Connect == nil {
|
|
server.Connect = server.connectChannel
|
|
}
|
|
if server.Submit == nil {
|
|
server.Submit = server.Upstream.Submit
|
|
}
|
|
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
|
|
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
|
|
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
|
|
mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink)
|
|
mux.HandleFunc("/downstream/recovery-candidates", server.handleDownstreamRecoveryCandidates)
|
|
mux.HandleFunc("/downstream/recovery-statuses", server.handleDownstreamRecoveryStatuses)
|
|
mux.HandleFunc("/downstream/recovery-overview", server.handleDownstreamRecoveryOverview)
|
|
}
|
|
|
|
func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var command ConnectChannelCommand
|
|
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid connect command: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validateConnectChannelCommand(command); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if configurer, ok := s.Limiter.(ratelimit.Configurer); ok {
|
|
if err := configurer.Configure(r.Context(), command.ChannelID, command.Channel.RateLimitPerSecond); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to configure gateway channel rate limit: %v", err), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
}
|
|
|
|
status, err := s.Connect(r.Context(), command)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to establish upstream pool: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var command queue.SubmitCommand
|
|
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid submit command: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if s.Limiter != nil {
|
|
if _, err := s.Limiter.Wait(r.Context(), command.ChannelID, command.Route.RateLimitPerSecond); err != nil {
|
|
http.Error(w, fmt.Sprintf("gateway channel rate limit unavailable: %v", err), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
}
|
|
result, err := s.Submit(r.Context(), command)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_ = json.NewEncoder(w).Encode(result)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
func (s Server) handleDownstreamReceipt(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var event inbound.DownstreamReceipt
|
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid downstream receipt: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
result, err := inbound.PushReceiptWithResult(event)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
func (s Server) handleDownstreamUplink(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var event inbound.DownstreamUplink
|
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid downstream uplink: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
result, err := inbound.PushUplinkWithResult(event)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
func (s Server) handleDownstreamRecoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if s.RecoveryCandidates == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode([]inbound.DownstreamPresence{})
|
|
return
|
|
}
|
|
candidates, err := s.RecoveryCandidates(r.Context())
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(candidates)
|
|
}
|
|
|
|
func (s Server) handleDownstreamRecoveryStatuses(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if s.RecoveryStatuses == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode([]inbound.DownstreamRecoveryStatus{})
|
|
return
|
|
}
|
|
statuses, err := s.RecoveryStatuses(r.Context())
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(statuses)
|
|
}
|
|
|
|
func (s Server) handleDownstreamRecoveryOverview(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
candidates := []inbound.DownstreamPresence{}
|
|
if s.RecoveryCandidates != nil {
|
|
result, err := s.RecoveryCandidates(r.Context())
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
candidates = result
|
|
}
|
|
statuses := []inbound.DownstreamRecoveryStatus{}
|
|
if s.RecoveryStatuses != nil {
|
|
result, err := s.RecoveryStatuses(r.Context())
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
|
|
return
|
|
}
|
|
statuses = result
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(DownstreamRecoveryOverview{
|
|
Candidates: candidates,
|
|
Statuses: statuses,
|
|
})
|
|
}
|
|
|
|
func (s Server) connectChannel(ctx context.Context, command ConnectChannelCommand) (ConnectionStateCallback, error) {
|
|
state, err := s.Upstream.ConnectChannel(ctx, queue.ConnectChannelCommand{
|
|
SchemaVersion: command.SchemaVersion,
|
|
MessageType: queue.MessageTypeConnectChannel,
|
|
TraceID: command.TraceID,
|
|
ChannelID: command.ChannelID,
|
|
ConnectionID: command.ConnectionID,
|
|
CreatedAt: time.Now().UTC(),
|
|
Reason: command.Reason,
|
|
DesiredConnections: command.DesiredConnections,
|
|
Channel: queue.ConnectChannelConfig{
|
|
Code: command.Channel.Code,
|
|
Name: command.Channel.Name,
|
|
GatewayHost: command.Channel.GatewayHost,
|
|
GatewayPort: command.Channel.GatewayPort,
|
|
Account: command.Channel.Account,
|
|
PasswordCipher: command.Channel.PasswordCipher,
|
|
SrcID: command.Channel.SrcID,
|
|
CMPPVersion: command.Channel.CMPPVersion,
|
|
RateLimitPerSecond: command.Channel.RateLimitPerSecond,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return ConnectionStateCallback{}, err
|
|
}
|
|
return ConnectionStateCallback{
|
|
ChannelID: state.ChannelID,
|
|
ConnectionID: state.ConnectionID,
|
|
Status: state.Status,
|
|
DesiredConnections: state.DesiredConnections,
|
|
CurrentConnections: state.CurrentConnections,
|
|
LastConnectedAt: state.LastConnectedAt,
|
|
LastDisconnectedAt: state.LastDisconnectedAt,
|
|
LastHeartbeatAt: state.LastHeartbeatAt,
|
|
ReconnectCount: state.ReconnectCount,
|
|
LastError: state.LastError,
|
|
}, nil
|
|
}
|
|
|
|
func validateConnectChannelCommand(command ConnectChannelCommand) error {
|
|
if command.MessageType != "ConnectChannel" {
|
|
return fmt.Errorf("unsupported messageType %q", command.MessageType)
|
|
}
|
|
if command.ChannelID == "" || command.ConnectionID == "" {
|
|
return fmt.Errorf("channelId and connectionId are required")
|
|
}
|
|
if command.Channel.GatewayHost == "" || command.Channel.GatewayPort <= 0 {
|
|
return fmt.Errorf("gatewayHost and gatewayPort are required")
|
|
}
|
|
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
|
|
return fmt.Errorf("account and passwordCipher are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func desiredConnections(value int) int {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return 1
|
|
}
|