H-294: secure TTS and ensure SIP cleanup

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-18 15:45:09 +08:00
co-authored by multica-agent
parent 854355d90a
commit f3043a0feb
3 changed files with 185 additions and 31 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}