diff --git a/README.md b/README.md index 57342e7..5845b68 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,6 @@ cmd/sip-demo 通过 UDP 向 61.132.228.221:5060 发起一通 IP 白名单、无 export BAILIAN_API_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/线路能力验证。 diff --git a/cmd/sip-demo/main.go b/cmd/sip-demo/main.go index 10a57be..66cf91b 100644 --- a/cmd/sip-demo/main.go +++ b/cmd/sip-demo/main.go @@ -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() diff --git a/cmd/sip-demo/main_test.go b/cmd/sip-demo/main_test.go index df19780..b93152b 100644 --- a/cmd/sip-demo/main_test.go +++ b/cmd/sip-demo/main_test.go @@ -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) +}