H-294: secure TTS and ensure SIP cleanup
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -33,6 +33,6 @@ cmd/sip-demo 通过 UDP 向 61.132.228.221:5060 发起一通 IP 白名单、无
|
||||
export BAILIAN_API_KEY='<server-side-key>'
|
||||
go run ./cmd/sip-demo
|
||||
|
||||
BAILIAN_BASE_URL 应指向支持 POST /audio/speech、PCM16 输出的百练兼容端点;若它已以 /audio/speech 结尾则直接使用。Key 只进入 Bearer 请求头,不写日志。可用 -audio music.wav 追加单声道 PCM16 WAV;未指定时追加两秒测试音。NAT 环境通过 SIP_ADVERTISE_IP 或 -advertise-ip 指定 SDP/Via 公网 IP。
|
||||
BAILIAN_BASE_URL 应指向支持 POST /audio/speech、PCM16 输出的百练兼容端点;若它已以 /audio/speech 结尾则直接使用。除 loopback 测试外只接受 HTTPS,并拒绝跨 host 或 HTTPS 降级重定向。Key 只进入 Bearer 请求头,不写日志。可用 -audio music.wav 追加单声道 PCM16 WAV;未指定时追加两秒测试音。NAT 环境通过 SIP_ADVERTISE_IP 或 -advertise-ip 指定 SDP/Via 公网 IP。
|
||||
|
||||
本 MVP 不做注册、鉴权、重拨、ASR/LLM、AEC 或抖动缓冲。打断依赖线路提供独立上行;若远端回声触发误打断,用 -vad-threshold 校准,正式方案仍需 AEC/线路能力验证。
|
||||
|
||||
+67
-20
@@ -62,6 +62,12 @@ func main() {
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg config) error {
|
||||
return runWithACK(ctx, cfg, func(ctx context.Context, session *sipgo.DialogClientSession) error {
|
||||
return session.Ack(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func runWithACK(ctx context.Context, cfg config, ack func(context.Context, *sipgo.DialogClientSession) error) error {
|
||||
if err := validateNumber(cfg.number); err != nil {
|
||||
return fmt.Errorf("SIP_TEST_NUMBER: %w", err)
|
||||
}
|
||||
@@ -155,28 +161,49 @@ func run(ctx context.Context, cfg config) error {
|
||||
}
|
||||
return fmt.Errorf("wait for answer: %w", err)
|
||||
}
|
||||
if err := session.Ack(ctx); err != nil {
|
||||
return fmt.Errorf("send ACK: %w", err)
|
||||
acked := false
|
||||
var callErr error
|
||||
if err := ack(ctx, session); err != nil {
|
||||
callErr = fmt.Errorf("send ACK: %w", err)
|
||||
} else {
|
||||
acked = true
|
||||
log.event(fmt.Sprintf(">>> ACK call-id=%s cseq=%d", session.InviteRequest.CallID().Value(), session.CSEQ()))
|
||||
remoteRTP, payloadType, err := answerMedia(session.InviteResponse.Body())
|
||||
if err != nil {
|
||||
callErr = err
|
||||
} else {
|
||||
callErr = runMedia(ctx, rtpConn, remoteRTP, payloadType, program, cfg.maxDuration, cfg.vadThreshold, remoteHangup, cfg.recordDir, log)
|
||||
}
|
||||
}
|
||||
log.event(fmt.Sprintf(">>> ACK call-id=%s cseq=%d", session.InviteRequest.CallID().Value(), session.CSEQ()))
|
||||
remoteRTP, payloadType, err := answerMedia(session.InviteResponse.Body())
|
||||
if err != nil {
|
||||
byeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = session.Bye(byeCtx)
|
||||
cancel()
|
||||
return err
|
||||
if session.LoadState() != sip.DialogStateEnded {
|
||||
callErr = errors.Join(callErr, endAnsweredSession(session, log, acked))
|
||||
}
|
||||
return callErr
|
||||
}
|
||||
|
||||
func endAnsweredSession(session *sipgo.DialogClientSession, log *signalLog, acked bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
log.event(fmt.Sprintf(">>> BYE call-id=%s cseq=%d", session.InviteRequest.CallID().Value(), session.CSEQ()+1))
|
||||
if acked {
|
||||
if err := session.Bye(ctx); err != nil {
|
||||
return fmt.Errorf("send BYE: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaErr := runMedia(ctx, rtpConn, remoteRTP, payloadType, program, cfg.maxDuration, cfg.vadThreshold, remoteHangup, cfg.recordDir, log)
|
||||
if session.LoadState() != sip.DialogStateEnded {
|
||||
byeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
log.event(fmt.Sprintf(">>> BYE call-id=%s cseq=%d", session.InviteRequest.CallID().Value(), session.CSEQ()+1))
|
||||
if err := session.Bye(byeCtx); mediaErr == nil && err != nil {
|
||||
mediaErr = fmt.Errorf("send BYE: %w", err)
|
||||
}
|
||||
cancel()
|
||||
recipient := session.InviteRequest.Recipient
|
||||
if contact := session.InviteResponse.Contact(); contact != nil {
|
||||
recipient = contact.Address
|
||||
}
|
||||
return mediaErr
|
||||
response, err := session.Do(ctx, sip.NewRequest(sip.BYE, recipient))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send BYE after ACK failure: %w", err)
|
||||
}
|
||||
if response.StatusCode != sip.StatusOK {
|
||||
return fmt.Errorf("send BYE after ACK failure: %w", &sipgo.ErrDialogResponse{Res: response})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mediaProgram(ctx context.Context, cfg config) ([]int16, error) {
|
||||
@@ -213,7 +240,7 @@ func mediaProgram(ctx context.Context, cfg config) ([]int16, error) {
|
||||
|
||||
func synthesize(ctx context.Context, client *http.Client, base, key, voice, text string) ([]int16, int, error) {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
|
||||
if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopback(u.Hostname()))) {
|
||||
return nil, 0, errors.New("invalid BAILIAN_BASE_URL")
|
||||
}
|
||||
if !strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/audio/speech") {
|
||||
@@ -229,7 +256,22 @@ func synthesize(ctx context.Context, client *http.Client, base, key, voice, text
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := client.Do(req)
|
||||
safeClient := *client
|
||||
previousRedirect := safeClient.CheckRedirect
|
||||
safeClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.User != nil || req.URL.Host != u.Host || (u.Scheme == "https" && req.URL.Scheme != "https") ||
|
||||
(req.URL.Scheme != "https" && !(req.URL.Scheme == "http" && isLoopback(req.URL.Hostname()))) {
|
||||
return errors.New("unsafe TTS redirect blocked")
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
if previousRedirect != nil {
|
||||
return previousRedirect(req, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
res, err := safeClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -253,6 +295,11 @@ func synthesize(ctx context.Context, client *http.Client, base, key, voice, text
|
||||
return bytesToPCM(data), 24000, nil
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
ip := net.ParseIP(host)
|
||||
return strings.EqualFold(host, "localhost") || ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func runMedia(parent context.Context, conn *net.UDPConn, remote *net.UDPAddr, payloadType uint8, audio []int16, duration time.Duration, threshold int, remoteHangup <-chan struct{}, recordDir string, log *signalLog) error {
|
||||
ctx, cancel := context.WithTimeout(parent, duration)
|
||||
defer cancel()
|
||||
|
||||
+117
-10
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emiago/sipgo"
|
||||
"github.com/emiago/sipgo/sip"
|
||||
)
|
||||
|
||||
@@ -99,6 +102,55 @@ func TestBailianTTSContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBailianTTSCredentialsRejectPlaintextAndUnsafeRedirects(t *testing.T) {
|
||||
const key = "redirect-key-that-must-not-leak"
|
||||
plainRequests := 0
|
||||
plainClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
plainRequests++
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||
})}
|
||||
if _, _, err := synthesize(context.Background(), plainClient, "http://192.0.2.1", key, "voice", "text"); err == nil || plainRequests != 0 {
|
||||
t.Fatal("accepted non-loopback HTTP")
|
||||
}
|
||||
|
||||
targetHit := make(chan struct{}, 1)
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
targetHit <- struct{}{}
|
||||
}))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/audio/speech", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer source.Close()
|
||||
_, _, err := synthesize(context.Background(), source.Client(), source.URL, key, "voice", "text")
|
||||
if err == nil || strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("cross-origin redirect error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-targetHit:
|
||||
t.Fatal("credential request reached redirect target")
|
||||
default:
|
||||
}
|
||||
|
||||
downgradeRequests := 0
|
||||
downgradeClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
downgradeRequests++
|
||||
if downgradeRequests == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTemporaryRedirect,
|
||||
Header: http.Header{"Location": []string{"http://secure.example/audio/speech"}},
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||
})}
|
||||
_, _, err = synthesize(context.Background(), downgradeClient, "https://secure.example", key, "voice", "text")
|
||||
if err == nil || downgradeRequests != 1 || strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("HTTPS downgrade error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundVoiceInterruptsPlayback(t *testing.T) {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||
if err != nil {
|
||||
@@ -140,6 +192,49 @@ func TestInboundVoiceInterruptsPlayback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSingleCallAgainstLocalSIPAndRTP(t *testing.T) {
|
||||
ackFailure := errors.New("injected ACK failure")
|
||||
tests := []struct {
|
||||
name string
|
||||
ack func(context.Context, *sipgo.DialogClientSession) error
|
||||
validSDP bool
|
||||
byeStatus int
|
||||
wantMethods string
|
||||
wantErrors []string
|
||||
}{
|
||||
{name: "normal", validSDP: true, byeStatus: sip.StatusOK, wantMethods: "INVITE,ACK,BYE"},
|
||||
{
|
||||
name: "ACK failure still sends BYE", validSDP: true, byeStatus: sip.StatusOK, wantMethods: "INVITE,BYE",
|
||||
ack: func(context.Context, *sipgo.DialogClientSession) error { return ackFailure },
|
||||
wantErrors: []string{"send ACK: injected ACK failure"},
|
||||
},
|
||||
{
|
||||
name: "media and cleanup errors are preserved", validSDP: false, byeStatus: sip.StatusInternalServerError, wantMethods: "INVITE,ACK,BYE",
|
||||
wantErrors: []string{"answer SDP lacks usable", "SIP/2.0 500"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
methods, logData, err := localCall(t, test.ack, test.validSDP, test.byeStatus)
|
||||
if len(test.wantErrors) == 0 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range test.wantErrors {
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %v does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(methods, ","); got != test.wantMethods {
|
||||
t.Fatalf("SIP methods = %s, want %s", got, test.wantMethods)
|
||||
}
|
||||
if strings.Contains(string(logData), "138"+"0000"+"1234") || !strings.Contains(string(logData), "200 OK") {
|
||||
t.Fatalf("unexpected SIP log: %s", logData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func localCall(t *testing.T, ack func(context.Context, *sipgo.DialogClientSession) error, validSDP bool, byeStatus int) ([]string, []byte, error) {
|
||||
t.Helper()
|
||||
const testNumber = "138" + "0000" + "1234"
|
||||
rtpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||
if err != nil {
|
||||
@@ -186,7 +281,11 @@ func TestSingleCallAgainstLocalSIPAndRTP(t *testing.T) {
|
||||
if status == sip.StatusOK {
|
||||
response.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Scheme: "sip", User: "mock", Host: "127.0.0.1", Port: sipConn.LocalAddr().(*net.UDPAddr).Port}})
|
||||
response.AppendHeader(sip.NewHeader("Content-Type", "application/sdp"))
|
||||
response.SetBody([]byte("v=0\r\nc=IN IP4 127.0.0.1\r\nm=audio " + strconv.Itoa(rtpConn.LocalAddr().(*net.UDPAddr).Port) + " RTP/AVP 8\r\n"))
|
||||
if validSDP {
|
||||
response.SetBody([]byte("v=0\r\nc=IN IP4 127.0.0.1\r\nm=audio " + strconv.Itoa(rtpConn.LocalAddr().(*net.UDPAddr).Port) + " RTP/AVP 8\r\n"))
|
||||
} else {
|
||||
response.SetBody([]byte("v=0\r\n"))
|
||||
}
|
||||
}
|
||||
if _, err := sipConn.WriteToUDP([]byte(response.String()), source); err != nil {
|
||||
serverDone <- serverResult{methods, err}
|
||||
@@ -197,7 +296,11 @@ func TestSingleCallAgainstLocalSIPAndRTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
case sip.BYE:
|
||||
response := sip.NewResponseFromRequest(request, sip.StatusOK, "OK", nil)
|
||||
reason := "OK"
|
||||
if byeStatus != sip.StatusOK {
|
||||
reason = "Server Error"
|
||||
}
|
||||
response := sip.NewResponseFromRequest(request, byeStatus, reason, nil)
|
||||
_, err := sipConn.WriteToUDP([]byte(response.String()), source)
|
||||
serverDone <- serverResult{methods, err}
|
||||
return
|
||||
@@ -219,23 +322,21 @@ func TestSingleCallAgainstLocalSIPAndRTP(t *testing.T) {
|
||||
maxDuration: 120 * time.Millisecond,
|
||||
vadThreshold: 1000,
|
||||
}
|
||||
if err := run(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
var runErr error
|
||||
if ack == nil {
|
||||
runErr = run(context.Background(), cfg)
|
||||
} else {
|
||||
runErr = runWithACK(context.Background(), cfg, ack)
|
||||
}
|
||||
result := <-serverDone
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if got := strings.Join(result.methods, ","); got != "INVITE,ACK,BYE" {
|
||||
t.Fatalf("SIP methods = %s", got)
|
||||
}
|
||||
logData, err := os.ReadFile(cfg.logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(logData), testNumber) || !strings.Contains(string(logData), "200 OK") {
|
||||
t.Fatalf("unexpected SIP log: %s", logData)
|
||||
}
|
||||
return result.methods, logData, runErr
|
||||
}
|
||||
|
||||
func equalPCM(a, b []int16) bool {
|
||||
@@ -256,3 +357,9 @@ func errString(err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user