golanggohlsrtmpwebrtcmedia-serverobs-studiortcprtmp-proxyrtmp-serverrtprtsprtsp-proxyrtsp-relayrtsp-serversrtstreamingwebrtc-proxy
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
578 lines
18 KiB
578 lines
18 KiB
package conf |
|
|
|
import ( |
|
"encoding/json" |
|
"fmt" |
|
"net" |
|
gourl "net/url" |
|
"reflect" |
|
"regexp" |
|
"strings" |
|
"time" |
|
|
|
"github.com/bluenviron/gortsplib/v4/pkg/base" |
|
) |
|
|
|
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.~]+$`) |
|
|
|
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, tilde, minus or slash") |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func srtCheckPassphrase(passphrase string) error { |
|
switch { |
|
case len(passphrase) < 10 || len(passphrase) > 79: |
|
return fmt.Errorf("must be between 10 and 79 characters") |
|
|
|
default: |
|
return nil |
|
} |
|
} |
|
|
|
// FindPathConf returns the configuration corresponding to the given path name. |
|
func FindPathConf(pathConfs map[string]*Path, name string) (string, *Path, []string, error) { |
|
err := isValidPathName(name) |
|
if err != nil { |
|
return "", nil, nil, fmt.Errorf("invalid path name: %w (%s)", err, name) |
|
} |
|
|
|
// normal path |
|
if pathConf, ok := pathConfs[name]; ok { |
|
return name, pathConf, nil, nil |
|
} |
|
|
|
// regular expression-based path |
|
for pathConfName, pathConf := range pathConfs { |
|
if pathConf.Regexp != nil && pathConfName != "all" && pathConfName != "all_others" { |
|
m := pathConf.Regexp.FindStringSubmatch(name) |
|
if m != nil { |
|
return pathConfName, pathConf, m, nil |
|
} |
|
} |
|
} |
|
|
|
// all_others |
|
for pathConfName, pathConf := range pathConfs { |
|
if pathConfName == "all" || pathConfName == "all_others" { |
|
m := pathConf.Regexp.FindStringSubmatch(name) |
|
if m != nil { |
|
return pathConfName, 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 Check() |
|
Name string `json:"name"` // filled by Check() |
|
|
|
// General |
|
Source string `json:"source"` |
|
SourceFingerprint string `json:"sourceFingerprint"` |
|
SourceOnDemand bool `json:"sourceOnDemand"` |
|
SourceOnDemandStartTimeout StringDuration `json:"sourceOnDemandStartTimeout"` |
|
SourceOnDemandCloseAfter StringDuration `json:"sourceOnDemandCloseAfter"` |
|
MaxReaders int `json:"maxReaders"` |
|
SRTReadPassphrase string `json:"srtReadPassphrase"` |
|
Fallback string `json:"fallback"` |
|
|
|
// Record and playback |
|
Record bool `json:"record"` |
|
Playback bool `json:"playback"` |
|
RecordPath string `json:"recordPath"` |
|
RecordFormat RecordFormat `json:"recordFormat"` |
|
RecordPartDuration StringDuration `json:"recordPartDuration"` |
|
RecordSegmentDuration StringDuration `json:"recordSegmentDuration"` |
|
RecordDeleteAfter StringDuration `json:"recordDeleteAfter"` |
|
|
|
// Authentication (deprecated) |
|
PublishUser *Credential `json:"publishUser,omitempty"` // deprecated |
|
PublishPass *Credential `json:"publishPass,omitempty"` // deprecated |
|
PublishIPs *IPNetworks `json:"publishIPs,omitempty"` // deprecated |
|
ReadUser *Credential `json:"readUser,omitempty"` // deprecated |
|
ReadPass *Credential `json:"readPass,omitempty"` // deprecated |
|
ReadIPs *IPNetworks `json:"readIPs,omitempty"` // deprecated |
|
|
|
// Publisher source |
|
OverridePublisher bool `json:"overridePublisher"` |
|
DisablePublisherOverride *bool `json:"disablePublisherOverride,omitempty"` // deprecated |
|
SRTPublishPassphrase string `json:"srtPublishPassphrase"` |
|
|
|
// RTSP source |
|
RTSPTransport RTSPTransport `json:"rtspTransport"` |
|
RTSPAnyPort bool `json:"rtspAnyPort"` |
|
SourceProtocol *RTSPTransport `json:"sourceProtocol,omitempty"` // deprecated |
|
SourceAnyPortEnable *bool `json:"sourceAnyPortEnable,omitempty"` // deprecated |
|
RTSPRangeType RTSPRangeType `json:"rtspRangeType"` |
|
RTSPRangeStart string `json:"rtspRangeStart"` |
|
|
|
// Redirect source |
|
SourceRedirect string `json:"sourceRedirect"` |
|
|
|
// Raspberry Pi Camera source |
|
RPICameraCamID int `json:"rpiCameraCamID"` |
|
RPICameraWidth int `json:"rpiCameraWidth"` |
|
RPICameraHeight int `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 int `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"` |
|
RPICameraIDRPeriod int `json:"rpiCameraIDRPeriod"` |
|
RPICameraBitrate int `json:"rpiCameraBitrate"` |
|
RPICameraProfile string `json:"rpiCameraProfile"` |
|
RPICameraLevel string `json:"rpiCameraLevel"` |
|
RPICameraAfMode string `json:"rpiCameraAfMode"` |
|
RPICameraAfRange string `json:"rpiCameraAfRange"` |
|
RPICameraAfSpeed string `json:"rpiCameraAfSpeed"` |
|
RPICameraLensPosition float64 `json:"rpiCameraLensPosition"` |
|
RPICameraAfWindow string `json:"rpiCameraAfWindow"` |
|
RPICameraTextOverlayEnable bool `json:"rpiCameraTextOverlayEnable"` |
|
RPICameraTextOverlay string `json:"rpiCameraTextOverlay"` |
|
|
|
// Hooks |
|
RunOnInit string `json:"runOnInit"` |
|
RunOnInitRestart bool `json:"runOnInitRestart"` |
|
RunOnDemand string `json:"runOnDemand"` |
|
RunOnDemandRestart bool `json:"runOnDemandRestart"` |
|
RunOnDemandStartTimeout StringDuration `json:"runOnDemandStartTimeout"` |
|
RunOnDemandCloseAfter StringDuration `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 * StringDuration(time.Second) |
|
pconf.SourceOnDemandCloseAfter = 10 * StringDuration(time.Second) |
|
|
|
// Record and playback |
|
pconf.Playback = true |
|
pconf.RecordPath = "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f" |
|
pconf.RecordFormat = RecordFormatFMP4 |
|
pconf.RecordPartDuration = 100 * StringDuration(time.Millisecond) |
|
pconf.RecordSegmentDuration = 3600 * StringDuration(time.Second) |
|
pconf.RecordDeleteAfter = 24 * 3600 * StringDuration(time.Second) |
|
|
|
// Publisher source |
|
pconf.OverridePublisher = true |
|
|
|
// 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.RPICameraIDRPeriod = 60 |
|
pconf.RPICameraBitrate = 1000000 |
|
pconf.RPICameraProfile = "main" |
|
pconf.RPICameraLevel = "4.1" |
|
pconf.RPICameraAfMode = "continuous" |
|
pconf.RPICameraAfRange = "normal" |
|
pconf.RPICameraAfSpeed = "normal" |
|
pconf.RPICameraTextOverlay = "%Y-%m-%d %H:%M:%S - MediaMTX" |
|
|
|
// Hooks |
|
pconf.RunOnDemandStartTimeout = 10 * StringDuration(time.Second) |
|
pconf.RunOnDemandCloseAfter = 10 * StringDuration(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 { |
|
enc, err := json.Marshal(pconf) |
|
if err != nil { |
|
panic(err) |
|
} |
|
|
|
var dest Path |
|
err = json.Unmarshal(enc, &dest) |
|
if err != nil { |
|
panic(err) |
|
} |
|
|
|
dest.Regexp = pconf.Regexp |
|
|
|
return &dest |
|
} |
|
|
|
func (pconf *Path) validate( |
|
conf *Conf, |
|
name string, |
|
deprecatedCredentialsMode bool, |
|
) 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 |
|
} |
|
|
|
// General |
|
|
|
if pconf.Source != "publisher" && pconf.Source != "redirect" && |
|
pconf.Regexp != nil && !pconf.SourceOnDemand { |
|
return fmt.Errorf("a path with a regular expression (or path 'all') and a static source" + |
|
" must have 'sourceOnDemand' set to true") |
|
} |
|
switch { |
|
case pconf.Source == "publisher": |
|
|
|
case strings.HasPrefix(pconf.Source, "rtsp://") || |
|
strings.HasPrefix(pconf.Source, "rtsps://"): |
|
_, err := base.ParseURL(pconf.Source) |
|
if err != nil { |
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source) |
|
} |
|
|
|
case strings.HasPrefix(pconf.Source, "rtmp://") || |
|
strings.HasPrefix(pconf.Source, "rtmps://"): |
|
u, err := gourl.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 := gourl.Parse(pconf.Source) |
|
if err != nil { |
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source) |
|
} |
|
if u.Scheme != "http" && u.Scheme != "https" { |
|
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 URL", pconf.Source) |
|
} |
|
|
|
case strings.HasPrefix(pconf.Source, "srt://"): |
|
|
|
_, err := gourl.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 := gourl.Parse(pconf.Source) |
|
if err != nil { |
|
return fmt.Errorf("'%s' is not a valid URL", pconf.Source) |
|
} |
|
|
|
case pconf.Source == "redirect": |
|
|
|
case pconf.Source == "rpiCamera": |
|
|
|
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'") |
|
} |
|
} |
|
if pconf.SRTReadPassphrase != "" { |
|
err := srtCheckPassphrase(pconf.SRTReadPassphrase) |
|
if err != nil { |
|
return fmt.Errorf("invalid 'readRTPassphrase': %w", err) |
|
} |
|
} |
|
if pconf.Fallback != "" { |
|
if strings.HasPrefix(pconf.Fallback, "/") { |
|
err := isValidPathName(pconf.Fallback[1:]) |
|
if err != nil { |
|
return fmt.Errorf("'%s': %w", pconf.Fallback, err) |
|
} |
|
} else { |
|
_, err := base.ParseURL(pconf.Fallback) |
|
if err != nil { |
|
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.Fallback) |
|
} |
|
} |
|
} |
|
|
|
// Authentication (deprecated) |
|
|
|
if deprecatedCredentialsMode { |
|
func() { |
|
var user Credential = "any" |
|
if pconf.PublishUser != nil { |
|
user = *pconf.PublishUser |
|
} |
|
|
|
var pass Credential |
|
if pconf.PublishPass != nil { |
|
pass = *pconf.PublishPass |
|
} |
|
|
|
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")} |
|
if pconf.PublishIPs != nil { |
|
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 { |
|
user = *pconf.ReadUser |
|
} |
|
|
|
var pass Credential |
|
if pconf.ReadPass != nil { |
|
pass = *pconf.ReadPass |
|
} |
|
|
|
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")} |
|
if pconf.ReadIPs != nil { |
|
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, |
|
}}, |
|
}) |
|
}() |
|
} |
|
|
|
// Publisher source |
|
|
|
if pconf.DisablePublisherOverride != nil { |
|
pconf.OverridePublisher = !*pconf.DisablePublisherOverride |
|
} |
|
if pconf.SRTPublishPassphrase != "" { |
|
if pconf.Source != "publisher" { |
|
return fmt.Errorf("'srtPublishPassphase' can only be used when source is 'publisher'") |
|
} |
|
|
|
err := srtCheckPassphrase(pconf.SRTPublishPassphrase) |
|
if err != nil { |
|
return fmt.Errorf("invalid 'srtPublishPassphrase': %w", err) |
|
} |
|
} |
|
|
|
// RTSP source |
|
|
|
if pconf.SourceProtocol != nil { |
|
pconf.RTSPTransport = *pconf.SourceProtocol |
|
} |
|
if pconf.SourceAnyPortEnable != nil { |
|
pconf.RTSPAnyPort = *pconf.SourceAnyPortEnable |
|
} |
|
|
|
// Redirect source |
|
|
|
if pconf.Source == "redirect" { |
|
if pconf.SourceRedirect == "" { |
|
return fmt.Errorf("source redirect must be filled") |
|
} |
|
|
|
_, err := base.ParseURL(pconf.SourceRedirect) |
|
if err != nil { |
|
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.SourceRedirect) |
|
} |
|
} |
|
|
|
// Raspberry Pi Camera source |
|
|
|
if pconf.Source == "rpiCamera" { |
|
for otherName, otherPath := range conf.Paths { |
|
if otherPath != pconf && otherPath != nil && |
|
otherPath.Source == "rpiCamera" && otherPath.RPICameraCamID == pconf.RPICameraCamID { |
|
return fmt.Errorf("'rpiCamera' with same camera ID %d is used as source in two paths, '%s' and '%s'", |
|
pconf.RPICameraCamID, name, otherName) |
|
} |
|
} |
|
} |
|
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") |
|
} |
|
|
|
// 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 strings.HasPrefix(pconf.Source, "rtsp://") || |
|
strings.HasPrefix(pconf.Source, "rtsps://") || |
|
strings.HasPrefix(pconf.Source, "rtmp://") || |
|
strings.HasPrefix(pconf.Source, "rtmps://") || |
|
strings.HasPrefix(pconf.Source, "http://") || |
|
strings.HasPrefix(pconf.Source, "https://") || |
|
strings.HasPrefix(pconf.Source, "udp://") || |
|
strings.HasPrefix(pconf.Source, "srt://") || |
|
strings.HasPrefix(pconf.Source, "whep://") || |
|
strings.HasPrefix(pconf.Source, "wheps://") || |
|
pconf.Source == "rpiCamera" |
|
} |
|
|
|
// 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 != "" |
|
}
|
|
|