160 lines
9.1 KiB
Go
160 lines
9.1 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"path"
|
|
"strconv"
|
|
"time"
|
|
|
|
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/oss"
|
|
"git.ipao.vip/rogee/go-sip/internal/store"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// RecordingServer is the Dispatcher-only endpoint for the approved recording
|
|
// lifecycle. It does not accept the old upload binding or provide a fallback
|
|
// when the active Agent session, mTLS peer or original execution is unknown.
|
|
type RecordingServer struct {
|
|
agentpb.UnimplementedAgentControlServiceServer
|
|
Store *store.CurrentStore
|
|
OSS *oss.Client
|
|
DispatcherID string
|
|
TrustedFingerprints map[string]struct{}
|
|
AuthorizeSession func(*agentpb.RequestMeta) error
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (s *RecordingServer) clock() time.Time {
|
|
if s.Now != nil {
|
|
return s.Now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func (s *RecordingServer) authorize(ctx context.Context, meta *agentpb.RequestMeta, dispatcherID string, tenantID int64, sourceEventID string) error {
|
|
if s == nil || s.Store == nil || s.OSS == nil || s.DispatcherID == "" || len(s.TrustedFingerprints) == 0 || s.AuthorizeSession == nil {
|
|
return status.Error(codes.FailedPrecondition, "Dispatcher recording authority is not configured")
|
|
}
|
|
if dispatcherID == "" || sourceEventID == "" || tenantID <= 0 {
|
|
return status.Error(codes.InvalidArgument, "recording fact requires original Dispatcher, tenant and call identity")
|
|
}
|
|
if dispatcherID != s.DispatcherID {
|
|
return status.Error(codes.PermissionDenied, "recording fact targets another Dispatcher")
|
|
}
|
|
if err := validateDispatcherPeer(ctx, meta, true, s.TrustedFingerprints, nil); err != nil {
|
|
return err
|
|
}
|
|
if err := s.AuthorizeSession(meta); err != nil {
|
|
log.Printf("recording RPC denied dispatcher=%s event=%s reason=inactive_agent_session", dispatcherID, sourceEventID)
|
|
return status.Error(codes.PermissionDenied, "Agent session is not active for this Dispatcher")
|
|
}
|
|
if err := s.Store.RequireReservedCall(dispatcherID, sourceEventID, tenantID); err != nil {
|
|
log.Printf("recording RPC denied dispatcher=%s event=%s reason=unbound_tenant_execution", dispatcherID, sourceEventID)
|
|
return status.Error(codes.FailedPrecondition, "recording source is not the approved tenant execution")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func recordingObjectKey(prefix, dispatcherID string, tenantID int64, sourceEventID, recordingID string) string {
|
|
identity := sha256.Sum256([]byte(dispatcherID + "\x00" + strconv.FormatInt(tenantID, 10) + "\x00" + sourceEventID + "\x00" + recordingID))
|
|
return path.Join(prefix, "recordings", fmt.Sprintf("%x.wav", identity))
|
|
}
|
|
|
|
func (s *RecordingServer) RequestRecordingUpload(ctx context.Context, req *agentpb.RequestRecordingUploadRequest) (*agentpb.RequestRecordingUploadResponse, error) {
|
|
if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil {
|
|
return nil, err
|
|
}
|
|
asset := req.GetAsset()
|
|
if asset == nil || req.GetUploadId() == "" || asset.GetKind() != agentpb.AssetKind_ASSET_KIND_RECORDING ||
|
|
asset.GetExecutionId() != req.GetSourceEventId() || asset.GetCallId() != req.GetSourceEventId() || asset.GetAssetId() == "" ||
|
|
asset.GetFormat() != "wav" || asset.GetChannels() < 1 || asset.GetChannels() > 2 || asset.GetSampleRateHz() != 16000 || asset.GetDurationMs() < 0 ||
|
|
asset.GetSizeBytes() <= 0 || asset.GetChecksumSha256() == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "recording asset does not match the approved call and media profile")
|
|
}
|
|
config := s.OSS.Config()
|
|
objectKey := recordingObjectKey(config.KeyPrefix, req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId(), asset.GetAssetId())
|
|
grant, err := s.OSS.Grant(ctx, req.GetUploadId(), objectKey, asset.GetChecksumSha256(), asset.GetSizeBytes(), s.clock())
|
|
if err != nil {
|
|
log.Printf("recording grant failed dispatcher=%s event=%s stage=presign", req.GetDispatcherId(), req.GetSourceEventId())
|
|
return nil, status.Error(codes.FailedPrecondition, "bounded OSS recording grant unavailable")
|
|
}
|
|
if grant.GetBucket() != config.Bucket || grant.GetObjectKey() != objectKey || grant.GetUploadId() != req.GetUploadId() || grant.GetRequiredChecksumSha256() != asset.GetChecksumSha256() || grant.GetMaxBytes() != asset.GetSizeBytes() {
|
|
log.Printf("recording grant failed dispatcher=%s event=%s stage=signed_target_mismatch", req.GetDispatcherId(), req.GetSourceEventId())
|
|
return nil, status.Error(codes.Internal, "OSS grant differs from approved original asset")
|
|
}
|
|
binding := store.CurrentRecordingGrant{
|
|
DispatcherID: req.GetDispatcherId(), SourceEventID: req.GetSourceEventId(), UploadID: req.GetUploadId(), RecordingID: asset.GetAssetId(),
|
|
Bucket: grant.GetBucket(), ObjectKey: grant.GetObjectKey(), ChecksumSHA256: asset.GetChecksumSha256(), SizeBytes: asset.GetSizeBytes(),
|
|
Format: asset.GetFormat(), Channels: int(asset.GetChannels()), SampleRateHz: int(asset.GetSampleRateHz()), DurationMS: asset.GetDurationMs(),
|
|
}
|
|
_, created, err := s.Store.BindRecordingUpload(binding)
|
|
if err != nil {
|
|
log.Printf("recording grant failed dispatcher=%s event=%s stage=bind error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err)
|
|
switch {
|
|
case errors.Is(err, store.ErrCurrentUploadConflict), errors.Is(err, store.ErrCurrentResultConflict):
|
|
return nil, status.Error(codes.AlreadyExists, "original recording target cannot be changed")
|
|
case errors.Is(err, store.ErrCurrentUploadAlreadyConfirmed):
|
|
return nil, status.Error(codes.FailedPrecondition, "recording is already confirmed; another PUT is forbidden")
|
|
default:
|
|
return nil, status.Error(codes.Internal, "original recording target could not be persisted")
|
|
}
|
|
}
|
|
log.Printf("recording grant bound dispatcher=%s event=%s new=%t", req.GetDispatcherId(), req.GetSourceEventId(), created)
|
|
return &agentpb.RequestRecordingUploadResponse{Grant: grant}, nil
|
|
}
|
|
|
|
func (s *RecordingServer) ReportCallEnded(ctx context.Context, req *agentpb.ReportCallEndedRequest) (*agentpb.ReportCallEndedResponse, error) {
|
|
if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.Store.FinishExecute(req.GetDispatcherId(), req.GetSourceEventId()); err != nil {
|
|
log.Printf("call end not committed dispatcher=%s event=%s stage=ack_and_release error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err)
|
|
return nil, status.Error(codes.FailedPrecondition, "confirmed call end could not be persisted with its acknowledgment")
|
|
}
|
|
log.Printf("call end committed dispatcher=%s event=%s", req.GetDispatcherId(), req.GetSourceEventId())
|
|
return &agentpb.ReportCallEndedResponse{Receipt: &agentpb.OperationReceipt{FactId: req.GetSourceEventId(), Result: agentpb.ResultCode_RESULT_CODE_APPLIED, AcceptedAtUnixMs: s.clock().UnixMilli()}}, nil
|
|
}
|
|
|
|
func (s *RecordingServer) ReportCallResult(ctx context.Context, req *agentpb.ReportCallResultRequest) (*agentpb.ReportCallResultResponse, error) {
|
|
if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(req.GetResultPayloadJson()) == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "final result payload is required")
|
|
}
|
|
var event store.CurrentOutboxEvent
|
|
var created bool
|
|
var err error
|
|
if observation := req.GetUpload(); observation != nil {
|
|
event, created, err = s.Store.RecordUploadedCallResult(req.GetDispatcherId(), req.GetSourceEventId(), req.GetResultPayloadJson(), store.CurrentUploadProof{
|
|
UploadID: observation.GetUploadId(), RecordingID: observation.GetRecordingId(), StatusCode: int(observation.GetPutStatusCode()),
|
|
SizeBytes: observation.GetSizeBytes(), SHA256: observation.GetChecksumSha256(),
|
|
})
|
|
} else {
|
|
event, created, err = s.Store.RecordCallResult(req.GetDispatcherId(), req.GetSourceEventId(), req.GetResultPayloadJson())
|
|
}
|
|
if err != nil {
|
|
log.Printf("final result not committed dispatcher=%s event=%s stage=outbox error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err)
|
|
switch {
|
|
case errors.Is(err, store.ErrCurrentResultInvalid):
|
|
return nil, status.Error(codes.InvalidArgument, "final result violates the approved MQ contract")
|
|
case errors.Is(err, store.ErrCurrentUploadUnverified), errors.Is(err, store.ErrCurrentEndUnconfirmed):
|
|
return nil, status.Error(codes.FailedPrecondition, "final result requires confirmed call end and original upload outcome")
|
|
case errors.Is(err, store.ErrCurrentResultConflict):
|
|
return nil, status.Error(codes.AlreadyExists, "call already has a different final result")
|
|
default:
|
|
return nil, status.Error(codes.Internal, "final result could not be persisted")
|
|
}
|
|
}
|
|
checksum := sha256.Sum256(event.Body)
|
|
log.Printf("final result committed dispatcher=%s event=%s outbox=%s new=%t", req.GetDispatcherId(), req.GetSourceEventId(), event.EventID, created)
|
|
return &agentpb.ReportCallResultResponse{Receipt: &agentpb.OperationReceipt{FactId: event.EventID, Result: agentpb.ResultCode_RESULT_CODE_ACCEPTED, ContentSha256: fmt.Sprintf("%x", checksum), AcceptedAtUnixMs: s.clock().UnixMilli()}}, nil
|
|
}
|