Path names are used as part of paths in several components: in the recorder, in the playback server and in every HTTP-based component (WebRTC, HLS, API). Special characters that allow to escape from the intended directory are now forbidden in order to prevent directory traversal attacks.
892 lines
28 KiB
Go
892 lines
28 KiB
Go
package conf
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
|
"github.com/bluenviron/mediacommon/v2/pkg/formats/mp4/codecs"
|
|
"github.com/bluenviron/mediacommon/v2/pkg/formats/pmp4"
|
|
"github.com/bluenviron/mediamtx/internal/logger"
|
|
)
|
|
|
|
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.]+$`)
|
|
|
|
// IsValidPathName checks whether the path name is valid.
|
|
func IsValidPathName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("cannot be empty")
|
|
}
|
|
|
|
if name[0] == '/' {
|
|
return fmt.Errorf("can't begin with a slash")
|
|
}
|
|
|
|
if name[len(name)-1] == '/' {
|
|
return fmt.Errorf("can't end with a slash")
|
|
}
|
|
|
|
if !rePathName.MatchString(name) {
|
|
return fmt.Errorf("can contain only alphanumeric characters, underscore, dot, minus, slash")
|
|
}
|
|
|
|
// prevent directory traversal attacks
|
|
for segment := range strings.SplitSeq(name, "/") {
|
|
if segment == "." || segment == ".." {
|
|
return fmt.Errorf("can't contain dot path segments")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkSRTPassphrase(passphrase string) error {
|
|
switch {
|
|
case len(passphrase) < 10 || len(passphrase) > 79:
|
|
return fmt.Errorf("must be between 10 and 79 characters")
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func checkRedirect(v string) error {
|
|
if strings.HasPrefix(v, "/") {
|
|
err := IsValidPathName(v[1:])
|
|
if err != nil {
|
|
return fmt.Errorf("'%s': %w", v, err)
|
|
}
|
|
} else {
|
|
_, err := base.ParseURL(v)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid RTSP URL", v)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkMP4MagicBytes(f io.ReadSeeker) error {
|
|
magicBytes := make([]byte, 4)
|
|
|
|
_, err := f.Seek(4, io.SeekStart)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.ReadFull(f, magicBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !bytes.Equal(magicBytes, []byte("ftyp")) {
|
|
return fmt.Errorf("file is not MP4, magic bytes are %v", magicBytes)
|
|
}
|
|
|
|
_, err = f.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkAlwaysAvailableFile(fpath string) error {
|
|
f, err := os.Open(fpath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
err = checkMP4MagicBytes(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var presentation pmp4.Presentation
|
|
err = presentation.Unmarshal(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(presentation.Tracks) == 0 {
|
|
return fmt.Errorf("file does not contain any track")
|
|
}
|
|
|
|
for _, track := range presentation.Tracks {
|
|
switch track.Codec.(type) {
|
|
case *codecs.AV1, *codecs.VP9, *codecs.H265, *codecs.H264, *codecs.Opus, *codecs.MPEG4Audio, *codecs.LPCM:
|
|
default:
|
|
return fmt.Errorf("unsupported codec %T", track.Codec)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindPathConf returns the configuration corresponding to the given path name.
|
|
func FindPathConf(pathConfs map[string]*Path, name string) (*Path, []string, error) {
|
|
// static path configuration
|
|
if pathConf, ok := pathConfs[name]; ok {
|
|
return pathConf, nil, nil
|
|
}
|
|
|
|
// regexp path configuration
|
|
|
|
err := IsValidPathName(name)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid path name: %w (%s)", err, name)
|
|
}
|
|
|
|
// gather and sort all regexp path configs
|
|
var regexpPathConfs []*Path
|
|
for _, pathConf := range pathConfs {
|
|
if pathConf.Regexp != nil {
|
|
regexpPathConfs = append(regexpPathConfs, pathConf)
|
|
}
|
|
}
|
|
sort.Slice(regexpPathConfs, func(i, j int) bool {
|
|
// keep all and all_others at the end
|
|
if regexpPathConfs[i].Name == "all" || regexpPathConfs[i].Name == "all_others" {
|
|
return false
|
|
}
|
|
if regexpPathConfs[j].Name == "all" || regexpPathConfs[j].Name == "all_others" {
|
|
return true
|
|
}
|
|
return regexpPathConfs[i].Name < regexpPathConfs[j].Name
|
|
})
|
|
|
|
// check path against regexp path configs
|
|
for _, pathConf := range regexpPathConfs {
|
|
m := pathConf.Regexp.FindStringSubmatch(name)
|
|
if m != nil {
|
|
return pathConf, m, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("path '%s' is not configured", name)
|
|
}
|
|
|
|
// Path is a path configuration.
|
|
type Path struct {
|
|
Regexp *regexp.Regexp `json:"-"` // filled by Validate()
|
|
Name string `json:"name"` // filled by Validate()
|
|
|
|
// General
|
|
Source string `json:"source"`
|
|
SourceFingerprint string `json:"sourceFingerprint"`
|
|
SourceOnDemand bool `json:"sourceOnDemand"`
|
|
SourceOnDemandStartTimeout Duration `json:"sourceOnDemandStartTimeout"`
|
|
SourceOnDemandCloseAfter Duration `json:"sourceOnDemandCloseAfter"`
|
|
MaxReaders int `json:"maxReaders"`
|
|
SRTReadPassphrase string `json:"srtReadPassphrase"`
|
|
Fallback *string `json:"fallback,omitempty" deprecated:"true"`
|
|
UseAbsoluteTimestamp bool `json:"useAbsoluteTimestamp"`
|
|
|
|
// Always available
|
|
AlwaysAvailable bool `json:"alwaysAvailable"`
|
|
AlwaysAvailableTracks []AlwaysAvailableTrack `json:"alwaysAvailableTracks"`
|
|
AlwaysAvailableFile string `json:"alwaysAvailableFile"`
|
|
|
|
// Record
|
|
Record bool `json:"record"`
|
|
Playback *bool `json:"playback,omitempty" deprecated:"true"`
|
|
RecordPath string `json:"recordPath"`
|
|
RecordFormat RecordFormat `json:"recordFormat"`
|
|
RecordPartDuration Duration `json:"recordPartDuration"`
|
|
RecordMaxPartSize StringSize `json:"recordMaxPartSize"`
|
|
RecordSegmentDuration Duration `json:"recordSegmentDuration"`
|
|
RecordDeleteAfter Duration `json:"recordDeleteAfter"`
|
|
|
|
// Authentication (deprecated)
|
|
PublishUser *Credential `json:"publishUser,omitempty" deprecated:"true"`
|
|
PublishPass *Credential `json:"publishPass,omitempty" deprecated:"true"`
|
|
PublishIPs *IPNetworks `json:"publishIPs,omitempty" deprecated:"true"`
|
|
ReadUser *Credential `json:"readUser,omitempty" deprecated:"true"`
|
|
ReadPass *Credential `json:"readPass,omitempty" deprecated:"true"`
|
|
ReadIPs *IPNetworks `json:"readIPs,omitempty" deprecated:"true"`
|
|
|
|
// Publisher source
|
|
OverridePublisher bool `json:"overridePublisher"`
|
|
DisablePublisherOverride *bool `json:"disablePublisherOverride,omitempty" deprecated:"true"`
|
|
SRTPublishPassphrase string `json:"srtPublishPassphrase"`
|
|
RTSPDemuxMpegts bool `json:"rtspDemuxMpegts"`
|
|
|
|
// RTSP source
|
|
RTSPTransport RTSPTransport `json:"rtspTransport"`
|
|
RTSPAnyPort bool `json:"rtspAnyPort"`
|
|
SourceProtocol *RTSPTransport `json:"sourceProtocol,omitempty" deprecated:"true"`
|
|
SourceAnyPortEnable *bool `json:"sourceAnyPortEnable,omitempty" deprecated:"true"`
|
|
RTSPRangeType RTSPRangeType `json:"rtspRangeType"`
|
|
RTSPRangeStart string `json:"rtspRangeStart"`
|
|
RTSPUDPReadBufferSize *uint `json:"rtspUDPReadBufferSize,omitempty" deprecated:"true"`
|
|
RTSPUDPSourcePortRange []uint `json:"rtspUDPSourcePortRange"`
|
|
|
|
// MPEG-TS source
|
|
MPEGTSUDPReadBufferSize *uint `json:"mpegtsUDPReadBufferSize,omitempty" deprecated:"true"`
|
|
|
|
// RTP source
|
|
RTPSDP string `json:"rtpSDP"`
|
|
RTPUDPReadBufferSize *uint `json:"rtpUDPReadBufferSize,omitempty" deprecated:"true"`
|
|
|
|
// WHEP source
|
|
WHEPBearerToken string `json:"whepBearerToken"`
|
|
WHEPSTUNGatherTimeout Duration `json:"whepSTUNGatherTimeout"`
|
|
WHEPHandshakeTimeout Duration `json:"whepHandshakeTimeout"`
|
|
WHEPTrackGatherTimeout Duration `json:"whepTrackGatherTimeout"`
|
|
|
|
// Redirect source
|
|
SourceRedirect string `json:"sourceRedirect"`
|
|
|
|
// Raspberry Pi Camera source
|
|
RPICameraCamID uint `json:"rpiCameraCamID"`
|
|
RPICameraSecondary bool `json:"rpiCameraSecondary"`
|
|
RPICameraWidth uint `json:"rpiCameraWidth"`
|
|
RPICameraHeight uint `json:"rpiCameraHeight"`
|
|
RPICameraHFlip bool `json:"rpiCameraHFlip"`
|
|
RPICameraVFlip bool `json:"rpiCameraVFlip"`
|
|
RPICameraBrightness float64 `json:"rpiCameraBrightness"`
|
|
RPICameraContrast float64 `json:"rpiCameraContrast"`
|
|
RPICameraSaturation float64 `json:"rpiCameraSaturation"`
|
|
RPICameraSharpness float64 `json:"rpiCameraSharpness"`
|
|
RPICameraExposure string `json:"rpiCameraExposure"`
|
|
RPICameraAWB string `json:"rpiCameraAWB"`
|
|
RPICameraAWBGains []float64 `json:"rpiCameraAWBGains"`
|
|
RPICameraDenoise string `json:"rpiCameraDenoise"`
|
|
RPICameraShutter uint `json:"rpiCameraShutter"`
|
|
RPICameraMetering string `json:"rpiCameraMetering"`
|
|
RPICameraGain float64 `json:"rpiCameraGain"`
|
|
RPICameraEV float64 `json:"rpiCameraEV"`
|
|
RPICameraROI string `json:"rpiCameraROI"`
|
|
RPICameraHDR bool `json:"rpiCameraHDR"`
|
|
RPICameraTuningFile string `json:"rpiCameraTuningFile"`
|
|
RPICameraMode string `json:"rpiCameraMode"`
|
|
RPICameraFPS float64 `json:"rpiCameraFPS"`
|
|
RPICameraAfMode string `json:"rpiCameraAfMode"`
|
|
RPICameraAfRange string `json:"rpiCameraAfRange"`
|
|
RPICameraAfSpeed string `json:"rpiCameraAfSpeed"`
|
|
RPICameraLensPosition float64 `json:"rpiCameraLensPosition"`
|
|
RPICameraAfWindow string `json:"rpiCameraAfWindow"`
|
|
RPICameraFlickerPeriod uint `json:"rpiCameraFlickerPeriod"`
|
|
RPICameraTextOverlayEnable bool `json:"rpiCameraTextOverlayEnable"`
|
|
RPICameraTextOverlay string `json:"rpiCameraTextOverlay"`
|
|
RPICameraCodec string `json:"rpiCameraCodec"`
|
|
RPICameraIDRPeriod uint `json:"rpiCameraIDRPeriod"`
|
|
RPICameraBitrate uint `json:"rpiCameraBitrate"`
|
|
RPICameraProfile *string `json:"rpiCameraProfile,omitempty" deprecated:"true"`
|
|
RPICameraLevel *string `json:"rpiCameraLevel,omitempty" deprecated:"true"`
|
|
RPICameraHardwareH264Profile string `json:"rpiCameraHardwareH264Profile"`
|
|
RPICameraHardwareH264Level string `json:"rpiCameraHardwareH264Level"`
|
|
RPICameraSoftwareH264Profile string `json:"rpiCameraSoftwareH264Profile"`
|
|
RPICameraSoftwareH264Level string `json:"rpiCameraSoftwareH264Level"`
|
|
RPICameraJPEGQuality *uint `json:"rpiCameraJPEGQuality,omitempty" deprecated:"true"`
|
|
RPICameraMJPEGQuality uint `json:"rpiCameraMJPEGQuality"`
|
|
RPICameraPrimaryName string `json:"-"` // filled by Validate()
|
|
RPICameraSecondaryWidth uint `json:"-"` // filled by Validate()
|
|
RPICameraSecondaryHeight uint `json:"-"` // filled by Validate()
|
|
RPICameraSecondaryFPS float64 `json:"-"` // filled by Validate()
|
|
RPICameraSecondaryMJPEGQuality uint `json:"-"` // filled by Validate()
|
|
|
|
// Hooks
|
|
RunOnInit string `json:"runOnInit"`
|
|
RunOnInitRestart bool `json:"runOnInitRestart"`
|
|
RunOnDemand string `json:"runOnDemand"`
|
|
RunOnDemandRestart bool `json:"runOnDemandRestart"`
|
|
RunOnDemandStartTimeout Duration `json:"runOnDemandStartTimeout"`
|
|
RunOnDemandCloseAfter Duration `json:"runOnDemandCloseAfter"`
|
|
RunOnUnDemand string `json:"runOnUnDemand"`
|
|
RunOnReady string `json:"runOnReady"`
|
|
RunOnReadyRestart bool `json:"runOnReadyRestart"`
|
|
RunOnNotReady string `json:"runOnNotReady"`
|
|
RunOnRead string `json:"runOnRead"`
|
|
RunOnReadRestart bool `json:"runOnReadRestart"`
|
|
RunOnUnread string `json:"runOnUnread"`
|
|
RunOnRecordSegmentCreate string `json:"runOnRecordSegmentCreate"`
|
|
RunOnRecordSegmentComplete string `json:"runOnRecordSegmentComplete"`
|
|
}
|
|
|
|
func (pconf *Path) setDefaults() {
|
|
// General
|
|
pconf.Source = "publisher"
|
|
pconf.SourceOnDemandStartTimeout = 10 * Duration(time.Second)
|
|
pconf.SourceOnDemandCloseAfter = 10 * Duration(time.Second)
|
|
|
|
// Record
|
|
pconf.RecordPath = "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f"
|
|
pconf.RecordFormat = RecordFormatFMP4
|
|
pconf.RecordPartDuration = Duration(1 * time.Second)
|
|
pconf.RecordMaxPartSize = 50 * 1024 * 1024
|
|
pconf.RecordSegmentDuration = 3600 * Duration(time.Second)
|
|
pconf.RecordDeleteAfter = 24 * 3600 * Duration(time.Second)
|
|
|
|
// Publisher source
|
|
pconf.OverridePublisher = true
|
|
|
|
// RTSP source
|
|
pconf.RTSPUDPSourcePortRange = []uint{10000, 65535}
|
|
|
|
// WHEP source
|
|
pconf.WHEPSTUNGatherTimeout = Duration(5 * time.Second)
|
|
pconf.WHEPHandshakeTimeout = Duration(10 * time.Second)
|
|
pconf.WHEPTrackGatherTimeout = Duration(2 * time.Second)
|
|
|
|
// Raspberry Pi Camera source
|
|
pconf.RPICameraWidth = 1920
|
|
pconf.RPICameraHeight = 1080
|
|
pconf.RPICameraContrast = 1
|
|
pconf.RPICameraSaturation = 1
|
|
pconf.RPICameraSharpness = 1
|
|
pconf.RPICameraExposure = "normal"
|
|
pconf.RPICameraAWB = "auto"
|
|
pconf.RPICameraAWBGains = []float64{0, 0}
|
|
pconf.RPICameraDenoise = "off"
|
|
pconf.RPICameraMetering = "centre"
|
|
pconf.RPICameraFPS = 30
|
|
pconf.RPICameraAfMode = "continuous"
|
|
pconf.RPICameraAfRange = "normal"
|
|
pconf.RPICameraAfSpeed = "normal"
|
|
pconf.RPICameraTextOverlay = "%Y-%m-%d %H:%M:%S - MediaMTX"
|
|
pconf.RPICameraCodec = "auto"
|
|
pconf.RPICameraIDRPeriod = 60
|
|
pconf.RPICameraBitrate = 5000000
|
|
pconf.RPICameraHardwareH264Profile = "main"
|
|
pconf.RPICameraHardwareH264Level = "4.1"
|
|
pconf.RPICameraSoftwareH264Profile = "baseline"
|
|
pconf.RPICameraSoftwareH264Level = "4.1"
|
|
pconf.RPICameraMJPEGQuality = 60
|
|
|
|
// Hooks
|
|
pconf.RunOnDemandStartTimeout = 10 * Duration(time.Second)
|
|
pconf.RunOnDemandCloseAfter = 10 * Duration(time.Second)
|
|
}
|
|
|
|
func newPath(defaults *Path, partial *OptionalPath) *Path {
|
|
pconf := &Path{}
|
|
copyStructFields(pconf, defaults)
|
|
copyStructFields(pconf, partial.Values)
|
|
return pconf
|
|
}
|
|
|
|
// Clone clones the configuration.
|
|
func (pconf Path) Clone() *Path {
|
|
cloned := deepClone(reflect.ValueOf(pconf)).Interface().(Path)
|
|
return &cloned
|
|
}
|
|
|
|
func (pconf *Path) validate(
|
|
conf *Conf,
|
|
name string,
|
|
deprecatedCredentialsMode bool,
|
|
l logger.Writer,
|
|
) error {
|
|
pconf.Name = name
|
|
|
|
switch {
|
|
case name == "all_others", name == "all":
|
|
pconf.Regexp = regexp.MustCompile("^.*$")
|
|
|
|
case name == "" || name[0] != '~': // normal path
|
|
err := IsValidPathName(name)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid path name '%s': %w", name, err)
|
|
}
|
|
|
|
default: // regular expression-based path
|
|
regexp, err := regexp.Compile(name[1:])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid regular expression: %s", name[1:])
|
|
}
|
|
pconf.Regexp = regexp
|
|
}
|
|
|
|
// common configuration errors
|
|
|
|
if pconf.SRTPublishPassphrase != "" && pconf.Source != "publisher" {
|
|
return fmt.Errorf("'srtPublishPassphase' can only be used when source is 'publisher'")
|
|
}
|
|
|
|
if pconf.Source != "redirect" && pconf.SourceRedirect != "" {
|
|
return fmt.Errorf("'sourceRedirect' is useless when source is not 'redirect'")
|
|
}
|
|
|
|
// General
|
|
|
|
switch {
|
|
case pconf.Source == "publisher":
|
|
if pconf.DisablePublisherOverride != nil {
|
|
l.Log(logger.Warn, "parameter 'disablePublisherOverride' is deprecated "+
|
|
"and has been replaced with 'overridePublisher'")
|
|
pconf.OverridePublisher = !*pconf.DisablePublisherOverride
|
|
}
|
|
|
|
if pconf.SRTPublishPassphrase != "" {
|
|
err := checkSRTPassphrase(pconf.SRTPublishPassphrase)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid 'srtPublishPassphrase': %w", err)
|
|
}
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "rtsp://") ||
|
|
strings.HasPrefix(pconf.Source, "rtsps://") ||
|
|
strings.HasPrefix(pconf.Source, "rtsp+http://") ||
|
|
strings.HasPrefix(pconf.Source, "rtsps+http://") ||
|
|
strings.HasPrefix(pconf.Source, "rtsp+ws://") ||
|
|
strings.HasPrefix(pconf.Source, "rtsps+ws://"):
|
|
_, err := url.Parse(pconf.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
|
|
}
|
|
|
|
if pconf.SourceProtocol != nil {
|
|
l.Log(logger.Warn, "parameter 'sourceProtocol' is deprecated and has been replaced with 'rtspTransport'")
|
|
pconf.RTSPTransport = *pconf.SourceProtocol
|
|
}
|
|
|
|
if pconf.SourceAnyPortEnable != nil {
|
|
l.Log(logger.Warn, "parameter 'sourceAnyPortEnable' is deprecated and has been replaced with 'rtspAnyPort'")
|
|
pconf.RTSPAnyPort = *pconf.SourceAnyPortEnable
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "rtmp://") ||
|
|
strings.HasPrefix(pconf.Source, "rtmps://"):
|
|
u, err := url.Parse(pconf.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
|
|
}
|
|
|
|
if u.User != nil {
|
|
pass, _ := u.User.Password()
|
|
user := u.User.Username()
|
|
if user != "" && pass == "" ||
|
|
user == "" && pass != "" {
|
|
return fmt.Errorf("username and password must be both provided")
|
|
}
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "http://") ||
|
|
strings.HasPrefix(pconf.Source, "https://"):
|
|
u, err := url.Parse(pconf.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
|
|
}
|
|
|
|
if u.User != nil {
|
|
pass, _ := u.User.Password()
|
|
user := u.User.Username()
|
|
if user != "" && pass == "" ||
|
|
user == "" && pass != "" {
|
|
return fmt.Errorf("username and password must be both provided")
|
|
}
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "udp://"):
|
|
_, _, err := net.SplitHostPort(pconf.Source[len("udp://"):])
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid UDP+MPEGTS URL", pconf.Source)
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "udp+mpegts://"):
|
|
_, _, err := net.SplitHostPort(pconf.Source[len("udp+mpegts://"):])
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid UDP+MPEGTS URL", pconf.Source)
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "unix+mpegts://"):
|
|
|
|
case strings.HasPrefix(pconf.Source, "udp+rtp://"):
|
|
_, _, err := net.SplitHostPort(pconf.Source[len("udp+rtp://"):])
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid UDP+RTP URL", pconf.Source)
|
|
}
|
|
|
|
if pconf.RTPSDP == "" {
|
|
return fmt.Errorf("`rtpSDP` was not provided")
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "unix+rtp://"):
|
|
l.Log(logger.Warn, "source 'unix+rtp' is deprecated due to intrinsic instability, use 'udp+rtp' instead")
|
|
if pconf.RTPSDP == "" {
|
|
return fmt.Errorf("`rtpSDP` was not provided")
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "srt://"):
|
|
_, err := url.Parse(pconf.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
|
|
}
|
|
|
|
case strings.HasPrefix(pconf.Source, "whep://") ||
|
|
strings.HasPrefix(pconf.Source, "wheps://"):
|
|
_, err := url.Parse(pconf.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
|
|
}
|
|
|
|
case pconf.Source == "redirect":
|
|
if pconf.SourceRedirect == "" {
|
|
return fmt.Errorf("source redirect must be filled")
|
|
}
|
|
|
|
err := checkRedirect(pconf.SourceRedirect)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
case pconf.Source == "rpiCamera":
|
|
|
|
if pconf.RPICameraWidth == 0 {
|
|
return fmt.Errorf("invalid 'rpiCameraWidth' value")
|
|
}
|
|
|
|
if pconf.RPICameraHeight == 0 {
|
|
return fmt.Errorf("invalid 'rpiCameraHeight' value")
|
|
}
|
|
|
|
switch pconf.RPICameraExposure {
|
|
case "normal", "short", "long", "custom":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraExposure' value")
|
|
}
|
|
|
|
switch pconf.RPICameraAWB {
|
|
case "auto", "incandescent", "tungsten", "fluorescent", "indoor", "daylight", "cloudy", "custom":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraAWB' value")
|
|
}
|
|
|
|
if len(pconf.RPICameraAWBGains) != 2 {
|
|
return fmt.Errorf("invalid 'rpiCameraAWBGains' value")
|
|
}
|
|
|
|
switch pconf.RPICameraDenoise {
|
|
case "off", "cdn_off", "cdn_fast", "cdn_hq":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraDenoise' value")
|
|
}
|
|
|
|
switch pconf.RPICameraMetering {
|
|
case "centre", "spot", "matrix", "custom":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraMetering' value")
|
|
}
|
|
|
|
switch pconf.RPICameraAfMode {
|
|
case "auto", "manual", "continuous":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraAfMode' value")
|
|
}
|
|
|
|
switch pconf.RPICameraAfRange {
|
|
case "normal", "macro", "full":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraAfRange' value")
|
|
}
|
|
|
|
switch pconf.RPICameraAfSpeed {
|
|
case "normal", "fast":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraAfSpeed' value")
|
|
}
|
|
|
|
if pconf.RPICameraProfile != nil {
|
|
l.Log(logger.Warn, "parameter 'rpiCameraProfile' is deprecated"+
|
|
" and has been replaced with 'rpiCameraHardwareH264Profile'")
|
|
pconf.RPICameraHardwareH264Profile = *pconf.RPICameraProfile
|
|
}
|
|
|
|
if pconf.RPICameraLevel != nil {
|
|
l.Log(logger.Warn, "parameter 'rpiCameraLevel' is deprecated"+
|
|
" and has been replaced with 'rpiCameraHardwareH264Level'")
|
|
pconf.RPICameraHardwareH264Level = *pconf.RPICameraLevel
|
|
}
|
|
|
|
switch pconf.RPICameraHardwareH264Profile {
|
|
case "baseline", "main", "high":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraHardwareH264Profile' value")
|
|
}
|
|
|
|
switch pconf.RPICameraHardwareH264Level {
|
|
case "4.0", "4.1", "4.2":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraHardwareH264Level' value")
|
|
}
|
|
|
|
switch pconf.RPICameraSoftwareH264Profile {
|
|
case "baseline", "main", "high":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraSoftwareH264Profile' value")
|
|
}
|
|
|
|
switch pconf.RPICameraSoftwareH264Level {
|
|
case "4.0", "4.1", "4.2":
|
|
default:
|
|
return fmt.Errorf("invalid 'rpiCameraSoftwareH264Level' value")
|
|
}
|
|
|
|
if pconf.RPICameraJPEGQuality != nil {
|
|
l.Log(logger.Warn, "parameter 'rpiCameraJPEGQuality' is deprecated"+
|
|
" and has been replaced with 'rpiCameraMJPEGQuality'")
|
|
pconf.RPICameraMJPEGQuality = *pconf.RPICameraJPEGQuality
|
|
}
|
|
|
|
if !pconf.RPICameraSecondary {
|
|
switch pconf.RPICameraCodec {
|
|
case "auto", "hardwareH264", "softwareH264":
|
|
default:
|
|
return fmt.Errorf("supported codecs for a primary RPI Camera stream are auto, hardwareH264, softwareH264")
|
|
}
|
|
|
|
for otherName, otherPath := range conf.Paths {
|
|
if otherPath != pconf &&
|
|
otherPath != nil &&
|
|
otherPath.Source == "rpiCamera" &&
|
|
otherPath.RPICameraCamID == pconf.RPICameraCamID &&
|
|
!otherPath.RPICameraSecondary {
|
|
return fmt.Errorf("'rpiCamera' with same camera ID %d is used as source in two paths, '%s' and '%s'",
|
|
pconf.RPICameraCamID, name, otherName)
|
|
}
|
|
}
|
|
} else {
|
|
switch pconf.RPICameraCodec {
|
|
case "auto", "mjpeg":
|
|
default:
|
|
return fmt.Errorf("supported codecs for a secondary RPI Camera stream are auto, mjpeg")
|
|
}
|
|
|
|
var primaryName string
|
|
var primary *Path
|
|
|
|
for otherPathName, otherPath := range conf.Paths {
|
|
if otherPath != pconf &&
|
|
otherPath != nil &&
|
|
otherPath.Source == "rpiCamera" &&
|
|
otherPath.RPICameraCamID == pconf.RPICameraCamID &&
|
|
!otherPath.RPICameraSecondary {
|
|
primaryName = otherPathName
|
|
primary = otherPath
|
|
break
|
|
}
|
|
}
|
|
|
|
if primary == nil {
|
|
return fmt.Errorf("cannot find a primary RPI Camera stream to associate with the secondary stream")
|
|
}
|
|
|
|
if primary.RPICameraSecondaryWidth != 0 {
|
|
return fmt.Errorf("a primary RPI Camera stream is associated with multiple secondary streams")
|
|
}
|
|
|
|
pconf.RPICameraPrimaryName = primaryName
|
|
primary.RPICameraSecondaryWidth = pconf.RPICameraWidth
|
|
primary.RPICameraSecondaryHeight = pconf.RPICameraHeight
|
|
primary.RPICameraSecondaryFPS = pconf.RPICameraFPS
|
|
primary.RPICameraSecondaryMJPEGQuality = pconf.RPICameraMJPEGQuality
|
|
}
|
|
|
|
default:
|
|
return fmt.Errorf("invalid source: '%s'", pconf.Source)
|
|
}
|
|
|
|
if pconf.SourceOnDemand {
|
|
if pconf.Source == "publisher" {
|
|
return fmt.Errorf("'sourceOnDemand' is useless when source is 'publisher'")
|
|
}
|
|
} else {
|
|
if pconf.Source != "publisher" && pconf.Source != "redirect" && pconf.Regexp != nil {
|
|
return fmt.Errorf("a path with a regular expression (or path 'all') and a static source" +
|
|
" must have 'sourceOnDemand' set to true")
|
|
}
|
|
}
|
|
|
|
if pconf.SRTReadPassphrase != "" {
|
|
err := checkSRTPassphrase(pconf.SRTReadPassphrase)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid 'readRTPassphrase': %w", err)
|
|
}
|
|
}
|
|
|
|
if pconf.Fallback != nil {
|
|
l.Log(logger.Warn, "the 'fallback' feature is deprecated, use 'alwaysAvailable' instead")
|
|
err := checkRedirect(*pconf.Fallback)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Always available
|
|
|
|
if pconf.AlwaysAvailable {
|
|
if pconf.Regexp != nil {
|
|
return fmt.Errorf("'alwaysAvailable' cannot be used in a path with a regular expression (or path 'all')")
|
|
}
|
|
|
|
if pconf.SourceOnDemand {
|
|
return fmt.Errorf("'sourceOnDemand' is not compatible with 'alwaysAvailable'")
|
|
}
|
|
|
|
if pconf.RunOnDemand != "" || pconf.RunOnUnDemand != "" {
|
|
return fmt.Errorf("'runOnDemand' and 'runOnUnDemand' cannot be used with 'alwaysAvailable'")
|
|
}
|
|
|
|
if pconf.AlwaysAvailableFile != "" {
|
|
if len(pconf.AlwaysAvailableTracks) != 0 {
|
|
return fmt.Errorf("'alwaysAvailableFile' and 'alwaysAvailableTracks' cannot be used together")
|
|
}
|
|
|
|
err := checkAlwaysAvailableFile(pconf.AlwaysAvailableFile)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid 'alwaysAvailableFile': %w", err)
|
|
}
|
|
} else if len(pconf.AlwaysAvailableTracks) == 0 {
|
|
return fmt.Errorf("'alwaysAvailableTracks' must contain at least one track")
|
|
}
|
|
|
|
if pconf.UseAbsoluteTimestamp {
|
|
return fmt.Errorf("'useAbsoluteTimestamp' cannot be used with 'alwaysAvailable'")
|
|
}
|
|
}
|
|
|
|
// Record
|
|
|
|
if pconf.Playback != nil {
|
|
l.Log(logger.Warn, "parameter 'playback' is deprecated and has no effect")
|
|
}
|
|
|
|
if !strings.Contains(pconf.RecordPath, "%path") {
|
|
return fmt.Errorf("'recordPath' must contain %%path")
|
|
}
|
|
|
|
if !strings.Contains(pconf.RecordPath, "%s") &&
|
|
(!strings.Contains(pconf.RecordPath, "%Y") ||
|
|
!strings.Contains(pconf.RecordPath, "%m") ||
|
|
!strings.Contains(pconf.RecordPath, "%d") ||
|
|
!strings.Contains(pconf.RecordPath, "%H") ||
|
|
!strings.Contains(pconf.RecordPath, "%M") ||
|
|
!strings.Contains(pconf.RecordPath, "%S")) {
|
|
return fmt.Errorf("'recordPath' must contain either %%s or %%Y %%m %%d %%H %%M %%S")
|
|
}
|
|
|
|
if conf.Playback && !strings.Contains(pconf.RecordPath, "%f") {
|
|
return fmt.Errorf("'recordPath' must contain %%f")
|
|
}
|
|
|
|
if pconf.RecordSegmentDuration > Duration(24*time.Hour) { // avoid overflowing DurationV0 of mvhd
|
|
return fmt.Errorf("maximum segment duration is 1 day")
|
|
}
|
|
|
|
if pconf.RecordDeleteAfter != 0 && pconf.RecordDeleteAfter < pconf.RecordSegmentDuration {
|
|
return fmt.Errorf("'recordDeleteAfter' cannot be lower than 'recordSegmentDuration'")
|
|
}
|
|
|
|
// Authentication (deprecated)
|
|
|
|
if deprecatedCredentialsMode {
|
|
func() {
|
|
var user Credential = "any"
|
|
if pconf.PublishUser != nil && *pconf.PublishUser != "" {
|
|
user = *pconf.PublishUser
|
|
}
|
|
|
|
var pass Credential
|
|
if pconf.PublishPass != nil && *pconf.PublishPass != "" {
|
|
pass = *pconf.PublishPass
|
|
}
|
|
|
|
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
|
|
if pconf.PublishIPs != nil && len(*pconf.PublishIPs) != 0 {
|
|
ips = *pconf.PublishIPs
|
|
}
|
|
|
|
pathName := name
|
|
if name == "all_others" || name == "all" {
|
|
pathName = "~^.*$"
|
|
}
|
|
|
|
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
|
|
User: user,
|
|
Pass: pass,
|
|
IPs: ips,
|
|
Permissions: []AuthInternalUserPermission{{
|
|
Action: AuthActionPublish,
|
|
Path: pathName,
|
|
}},
|
|
})
|
|
}()
|
|
|
|
func() {
|
|
var user Credential = "any"
|
|
if pconf.ReadUser != nil && *pconf.ReadUser != "" {
|
|
user = *pconf.ReadUser
|
|
}
|
|
|
|
var pass Credential
|
|
if pconf.ReadPass != nil && *pconf.ReadPass != "" {
|
|
pass = *pconf.ReadPass
|
|
}
|
|
|
|
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
|
|
if pconf.ReadIPs != nil && len(*pconf.ReadIPs) != 0 {
|
|
ips = *pconf.ReadIPs
|
|
}
|
|
|
|
pathName := name
|
|
if name == "all_others" || name == "all" {
|
|
pathName = "~^.*$"
|
|
}
|
|
|
|
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
|
|
User: user,
|
|
Pass: pass,
|
|
IPs: ips,
|
|
Permissions: []AuthInternalUserPermission{{
|
|
Action: AuthActionRead,
|
|
Path: pathName,
|
|
}},
|
|
})
|
|
}()
|
|
}
|
|
|
|
// Hooks
|
|
|
|
if pconf.RunOnInit != "" && pconf.Regexp != nil {
|
|
return fmt.Errorf("a path with a regular expression (or path 'all')" +
|
|
" does not support option 'runOnInit'; use another path")
|
|
}
|
|
|
|
if (pconf.RunOnDemand != "" || pconf.RunOnUnDemand != "") && pconf.Source != "publisher" {
|
|
return fmt.Errorf("'runOnDemand' and 'runOnUnDemand' can be used only when source is 'publisher'")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Equal checks whether two Paths are equal.
|
|
func (pconf *Path) Equal(other *Path) bool {
|
|
return reflect.DeepEqual(pconf, other)
|
|
}
|
|
|
|
// HasStaticSource checks whether the path has a static source.
|
|
func (pconf Path) HasStaticSource() bool {
|
|
return pconf.Source != "publisher" && pconf.Source != "redirect"
|
|
}
|
|
|
|
// HasOnDemandStaticSource checks whether the path has a on demand static source.
|
|
func (pconf Path) HasOnDemandStaticSource() bool {
|
|
return pconf.HasStaticSource() && pconf.SourceOnDemand
|
|
}
|
|
|
|
// HasOnDemandPublisher checks whether the path has a on-demand publisher.
|
|
func (pconf Path) HasOnDemandPublisher() bool {
|
|
return pconf.RunOnDemand != ""
|
|
}
|