mediamtx/internal/adapter/api.go

532 lines
14 KiB
Go

package adapter
import (
"bufio"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// APIServer provides REST API for the adapter
type APIServer struct {
adapter *Adapter
port int
server *http.Server
// Discovery cache
discoveredDevices []DiscoveredDevice
discoveryMu sync.RWMutex
lastDiscovery time.Time
}
// DiscoveredDevice represents an ONVIF device found on network
type DiscoveredDevice struct {
IP string `json:"ip"`
Port int `json:"port"`
XAddr string `json:"xaddr"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
Serial string `json:"serial,omitempty"`
Firmware string `json:"firmware,omitempty"`
Profiles []ProfileInfo `json:"profiles,omitempty"`
Services []string `json:"services,omitempty"`
HasMedia bool `json:"hasMedia"`
DiscoveredAt string `json:"discoveredAt"`
}
// ProfileInfo contains stream profile information
type ProfileInfo struct {
Token string `json:"token"`
Name string `json:"name"`
RTSPPath string `json:"rtspPath,omitempty"` // Path without credentials
}
// DiscoverResponse is the API response for /api/discover
type DiscoverResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Devices []DiscoveredDevice `json:"devices"`
ScanTime string `json:"scanTime"`
Duration string `json:"duration"`
}
// AuthRequest is the request body for /api/auth
type AuthRequest struct {
IP string `json:"ip"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
}
// AuthResponse is the API response for /api/auth
type AuthResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
RTSPURL string `json:"rtspUrl,omitempty"`
RTSPURLs []string `json:"rtspUrls,omitempty"` // Multiple profiles
Profiles []ProfileInfo `json:"profiles,omitempty"`
}
// NewAPIServer creates a new API server
func NewAPIServer(adapter *Adapter, port int) *APIServer {
return &APIServer{
adapter: adapter,
port: port,
}
}
// Start starts the API server
func (s *APIServer) Start() error {
mux := http.NewServeMux()
// CORS middleware wrapper
corsHandler := func(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
h(w, r)
}
}
// Routes
mux.HandleFunc("/api/discover", corsHandler(s.handleDiscover))
mux.HandleFunc("/api/auth", corsHandler(s.handleAuth))
mux.HandleFunc("/api/devices", corsHandler(s.handleGetDevices))
mux.HandleFunc("/api/health", corsHandler(s.handleHealth))
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
}
s.adapter.log("API server starting on port %d", s.port)
go func() {
if err := s.server.ListenAndServe(); err != http.ErrServerClosed {
s.adapter.logError("API server error: %v", err)
}
}()
return nil
}
// Stop stops the API server
func (s *APIServer) Stop() {
if s.server != nil {
s.server.Close()
}
}
// handleHealth returns server health status
func (s *APIServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
}
// handleDiscover scans network for ONVIF devices
func (s *APIServer) handleDiscover(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
s.adapter.log("API: Starting network discovery...")
startTime := time.Now()
// Use ONVIF WS-Discovery
onvif := NewONVIFClient(s.adapter.log)
cameras, err := onvif.Discover(5 * time.Second)
if err != nil {
s.adapter.logError("Discovery failed: %v", err)
json.NewEncoder(w).Encode(DiscoverResponse{
Success: false,
Message: err.Error(),
Devices: []DiscoveredDevice{},
ScanTime: startTime.Format(time.RFC3339),
Duration: time.Since(startTime).String(),
})
return
}
// Convert to DiscoveredDevice
var devices []DiscoveredDevice
for _, cam := range cameras {
device := DiscoveredDevice{
IP: cam.IP,
Port: cam.Port,
XAddr: cam.XAddr,
Serial: cam.Serial,
HasMedia: false,
DiscoveredAt: time.Now().Format(time.RFC3339),
}
// Try to get device info (without auth - some cameras allow this)
if cam.XAddr != "" {
info, err := onvif.GetDeviceInfo(cam.XAddr, "", "")
if err == nil {
device.Manufacturer = info["Manufacturer"]
device.Model = info["Model"]
device.Serial = info["SerialNumber"]
device.Firmware = info["FirmwareVersion"]
}
// Get services to check for media
services, err := onvif.GetServices(cam.XAddr, "", "")
if err == nil {
for ns := range services {
device.Services = append(device.Services, ns)
if containsMedia(ns) {
device.HasMedia = true
}
}
}
}
devices = append(devices, device)
}
// Cache results
s.discoveryMu.Lock()
s.discoveredDevices = devices
s.lastDiscovery = time.Now()
s.discoveryMu.Unlock()
s.adapter.log("API: Discovery completed, found %d device(s)", len(devices))
json.NewEncoder(w).Encode(DiscoverResponse{
Success: true,
Message: fmt.Sprintf("Found %d device(s)", len(devices)),
Devices: devices,
ScanTime: startTime.Format(time.RFC3339),
Duration: time.Since(startTime).String(),
})
}
// handleGetDevices returns cached discovered devices
func (s *APIServer) handleGetDevices(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
s.discoveryMu.RLock()
devices := s.discoveredDevices
lastScan := s.lastDiscovery
s.discoveryMu.RUnlock()
json.NewEncoder(w).Encode(map[string]interface{}{
"devices": devices,
"lastScan": lastScan.Format(time.RFC3339),
"cached": !lastScan.IsZero(),
})
}
// handleAuth authenticates with a device and gets RTSP URLs
func (s *APIServer) handleAuth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: "Invalid request body",
})
return
}
if req.IP == "" {
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: "IP is required",
})
return
}
if req.Port == 0 {
req.Port = 80
}
s.adapter.log("API: Authenticating with %s:%d (user: %s)", req.IP, req.Port, req.Username)
// Create ONVIF client with credentials
onvif := NewONVIFClientWithAuth(s.adapter.log, req.Username, req.Password)
// Build device URL
deviceURL := fmt.Sprintf("http://%s:%d/onvif/device_service", req.IP, req.Port)
// Get services to find media URL
services, err := onvif.GetServices(deviceURL, req.Username, req.Password)
if err != nil {
s.adapter.logError("Failed to get services: %v", err)
// If the request port is 554 (RTSP) or we timed out contacting ONVIF,
// try RTSP probe as a fallback (device might expose RTSP only)
if req.Port == 554 {
s.adapter.log("API: ONVIF failed on port 554; attempting RTSP probe fallback")
rtspUrls := s.probeRTSP(req.IP, req.Port, req.Username, req.Password)
if len(rtspUrls) > 0 {
// store credential and return success
serial := fmt.Sprintf("%s_%d", req.IP, req.Port)
s.adapter.credentialsMu.Lock()
s.adapter.credentials[serial] = &CameraCredentials{
Serial: serial,
Username: req.Username,
Password: req.Password,
RTSPURL: rtspUrls[0],
}
s.adapter.credentialsMu.Unlock()
s.adapter.log("API: RTSP probe successful, found %d URL(s)", len(rtspUrls))
json.NewEncoder(w).Encode(AuthResponse{
Success: true,
Message: fmt.Sprintf("RTSP probe found %d URL(s)", len(rtspUrls)),
RTSPURL: rtspUrls[0],
RTSPURLs: rtspUrls,
})
return
}
}
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: fmt.Sprintf("Failed to connect: %v", err),
})
return
}
// Find media service URL
var mediaURL string
for ns, url := range services {
if containsMedia(ns) {
mediaURL = url
break
}
}
if mediaURL == "" {
mediaURL = fmt.Sprintf("http://%s:%d/onvif/media_service", req.IP, req.Port)
}
s.adapter.log("API: Media service URL: %s", mediaURL)
// Get profiles
profiles, err := onvif.GetProfiles(mediaURL, req.Username, req.Password)
if err != nil {
s.adapter.logError("Failed to get profiles: %v", err)
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: fmt.Sprintf("Authentication failed: %v", err),
})
return
}
if len(profiles) == 0 {
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: "No stream profiles found",
})
return
}
// Get stream URIs for each profile
var rtspURLs []string
var profileInfos []ProfileInfo
for _, profile := range profiles {
streamURI, err := onvif.GetStreamURI(mediaURL, req.Username, req.Password, profile.Token)
if err != nil {
s.adapter.log("Failed to get stream URI for profile %s: %v", profile.Token, err)
continue
}
rtspURLs = append(rtspURLs, streamURI)
profileInfos = append(profileInfos, ProfileInfo{
Token: profile.Token,
Name: profile.Name,
RTSPPath: streamURI,
})
s.adapter.log("API: Profile %s (%s) -> %s", profile.Token, profile.Name, streamURI)
}
if len(rtspURLs) == 0 {
json.NewEncoder(w).Encode(AuthResponse{
Success: false,
Message: "Failed to get stream URLs",
})
return
}
// Store credentials in adapter
serial := fmt.Sprintf("%s_%d", req.IP, req.Port)
s.adapter.credentialsMu.Lock()
s.adapter.credentials[serial] = &CameraCredentials{
Serial: serial,
Username: req.Username,
Password: req.Password,
RTSPURL: rtspURLs[0],
}
s.adapter.credentialsMu.Unlock()
s.adapter.log("API: Authentication successful, found %d stream(s)", len(rtspURLs))
json.NewEncoder(w).Encode(AuthResponse{
Success: true,
Message: fmt.Sprintf("Found %d stream profile(s)", len(rtspURLs)),
RTSPURL: rtspURLs[0],
RTSPURLs: rtspURLs,
Profiles: profileInfos,
})
}
// probeRTSP tries common RTSP paths on the device and returns successful URLs
func (s *APIServer) probeRTSP(ip string, port int, username, password string) []string {
commonPaths := []string{
"/",
"/cam/realmonitor?channel=1",
"/cam/realmonitor?channel=1&subtype=0",
"/Streaming/Channels/101",
"/live.sdp",
}
var found []string
for _, p := range commonPaths {
ok, err := sendRTSPDescribe(ip, port, p, username, password)
if err != nil {
s.adapter.log("RTSP probe %s%s failed: %v", ip, p, err)
continue
}
if ok {
// build URL with credentials if provided
url := fmt.Sprintf("rtsp://%s:%d%s", ip, port, p)
if username != "" {
// encode naive - assume no special chars
url = fmt.Sprintf("rtsp://%s:%s@%s:%d%s", username, password, ip, port, p)
}
found = append(found, url)
}
}
return found
}
// sendRTSPDescribe sends an RTSP DESCRIBE and handles Digest auth if required
func sendRTSPDescribe(ip string, port int, path, username, password string) (bool, error) {
addr := fmt.Sprintf("%s:%d", ip, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false, err
}
defer conn.Close()
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
uri := fmt.Sprintf("rtsp://%s:%d%s", ip, port, path)
// send initial DESCRIBE
req := fmt.Sprintf("DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nAccept: application/sdp\r\n\r\n", uri)
if _, err := conn.Write([]byte(req)); err != nil {
return false, err
}
reader := bufio.NewReader(conn)
// read status line
statusLine, err := reader.ReadString('\n')
if err != nil {
return false, err
}
statusLine = strings.TrimSpace(statusLine)
// Example: RTSP/1.0 401 Unauthorized
if strings.Contains(statusLine, "200") {
return true, nil
}
// read headers
headers := map[string]string{}
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if line == "" {
break
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
if auth, ok := headers["WWW-Authenticate"]; ok && strings.Contains(auth, "Digest") && username != "" {
// parse realm/nonce
realm := extractDigestParam(auth, "realm")
nonce := extractDigestParam(auth, "nonce")
qop := extractDigestParam(auth, "qop")
ha1 := md5Hash(username + ":" + realm + ":" + password)
ha2 := md5Hash("DESCRIBE:" + getURIPath(uri))
nc := "00000001"
cnonce := fmt.Sprintf("%08x", time.Now().UnixNano())
var response string
if qop != "" {
response = md5Hash(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2)
} else {
response = md5Hash(ha1 + ":" + nonce + ":" + ha2)
}
authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, username, realm, nonce, getURIPath(uri), response)
if qop != "" {
authHeader += fmt.Sprintf(", qop=%s, nc=%s, cnonce=\"%s\"", qop, nc, cnonce)
}
// open new connection for authenticated DESCRIBE
conn2, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false, err
}
defer conn2.Close()
req2 := fmt.Sprintf("DESCRIBE %s RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\nAuthorization: %s\r\n\r\n", uri, authHeader)
if _, err := conn2.Write([]byte(req2)); err != nil {
return false, err
}
r2 := bufio.NewReader(conn2)
status2, err := r2.ReadString('\n')
if err != nil {
return false, err
}
status2 = strings.TrimSpace(status2)
if strings.Contains(status2, "200") {
return true, nil
}
}
return false, nil
}
// Helper to check if namespace contains media service
func containsMedia(ns string) bool {
return len(ns) > 0 && (
ns == "http://www.onvif.org/ver10/media/wsdl" ||
ns == "http://www.onvif.org/ver20/media/wsdl" ||
// Check if contains "media" substring
(len(ns) > 5 && (ns[len(ns)-5:] == "media" ||
(len(ns) > 10 && ns[len(ns)-10:] == "media/wsdl"))))
}