feat: 重建 ONVIF 与 RTSP 视频接入 (#66)

This commit is contained in:
QiuSW
2026-08-14 16:50:00 +08:00
parent 5856b76de9
commit 2bb1614a3c
25 changed files with 1531 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
package rtsp
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
)
type Credential struct{ Username, Password string }
type Result struct {
Status string `json:"status"`
LatencyMS int64 `json:"latencyMs"`
Detail string `json:"detail"`
}
type Verifier interface {
Verify(context.Context, string, Credential) (Result, error)
}
type NetVerifier struct {
Timeout time.Duration
Policy onvif.Policy
}
func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
parsed, ip, err := v.Policy.ValidateURL(ctx, raw, "rtsp")
if err != nil {
return Result{}, err
}
port := parsed.Port()
if port == "" {
port = "554"
}
timeout := v.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
started := time.Now()
connection, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
if err != nil {
return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(timeout))
authorization := ""
if credential.Username != "" {
authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
}
request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
if _, err = connection.Write([]byte(request)); err != nil {
return Result{}, err
}
line, err := bufio.NewReader(connection).ReadString('\n')
if err != nil {
return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
}
result := Result{Status: "ready", Detail: "码流可访问", LatencyMS: time.Since(started).Milliseconds()}
if strings.Contains(line, " 401 ") {
result.Status = "authentication_failed"
result.Detail = "设备拒绝了当前 RTSP 凭据"
} else if !strings.Contains(line, " 200 ") {
result.Status = "failed"
result.Detail = "设备返回非成功状态"
}
return result, nil
}
@@ -0,0 +1,55 @@
package rtsp
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
)
func TestVerifierUsesCredentialHeaderWithoutPuttingItInURI(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
requestChannel := make(chan string, 1)
go func() {
connection, _ := listener.Accept()
if connection == nil {
return
}
defer connection.Close()
reader := bufio.NewReader(connection)
request, _ := reader.ReadString('\n')
headers := request
for {
line, _ := reader.ReadString('\n')
headers += line
if line == "\r\n" || line == "" {
break
}
}
requestChannel <- headers
_, _ = connection.Write([]byte("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n"))
}()
policy, _ := onvif.ParsePolicy("127.0.0.0/8")
result, err := (NetVerifier{Timeout: time.Second, Policy: policy}).Verify(context.Background(), "rtsp://"+listener.Addr().String()+"/live", Credential{Username: "synthetic", Password: "synthetic"})
if err != nil || result.Status != "ready" {
t.Fatalf("result=%#v err=%v", result, err)
}
request := <-requestChannel
if strings.Contains(strings.Split(request, "\r\n")[0], "synthetic") {
t.Fatal("credentials leaked into request URI")
}
if !strings.Contains(request, "Authorization: Basic ") {
t.Fatal("authorization header missing")
}
if _, err = (NetVerifier{Policy: policy}).Verify(context.Background(), "rtsp://user:pass@"+listener.Addr().String()+"/live", Credential{}); err == nil {
t.Fatal("credential-bearing URI accepted")
}
}