From 9efe23a063666536c0020ae397b3eb55acf9aad4 Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 09:53:51 -0500 Subject: [PATCH 1/7] refactor(api): moved StreamNotFound from api to streams package In all cases where this was used by other packages, those packages also imported 'streams' and in some cases this eliminated the dependency on 'api' for that package. --- internal/api/api.go | 2 -- internal/hass/api.go | 2 +- internal/hls/hls.go | 2 +- internal/hls/ws.go | 3 +-- internal/homekit/api.go | 2 +- internal/http/http.go | 2 +- internal/mjpeg/mjpeg.go | 10 +++++----- internal/mp4/mp4.go | 4 ++-- internal/mp4/ws.go | 5 ++--- internal/mpeg/mpeg.go | 6 +++--- internal/rtmp/rtmp.go | 4 ++-- internal/streams/stream.go | 2 ++ internal/webrtc/server.go | 5 ++--- internal/webrtc/webrtc.go | 2 +- internal/webtorrent/init.go | 2 +- 15 files changed, 25 insertions(+), 28 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index dfb651177..7c919568b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -192,8 +192,6 @@ func Response(w http.ResponseWriter, body any, contentType string) { } } -const StreamNotFound = "stream not found" - var allowPaths []string var basePath string var log zerolog.Logger diff --git a/internal/hass/api.go b/internal/hass/api.go index 9f110fc8b..a7a5aa2ab 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -47,7 +47,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) { name := r.RequestURI[8 : 8+i] stream := streams.Get(name) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 5c136450d..2e4d49af1 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -52,7 +52,7 @@ func handlerStream(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/hls/ws.go b/internal/hls/ws.go index 00eedfe2a..5b97de8c1 100644 --- a/internal/hls/ws.go +++ b/internal/hls/ws.go @@ -4,7 +4,6 @@ import ( "errors" "time" - "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mp4" @@ -13,7 +12,7 @@ import ( func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } codecs := msg.String() diff --git a/internal/homekit/api.go b/internal/homekit/api.go index 885a40fa0..cb88193f3 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -151,7 +151,7 @@ func apiPair(id, url string) error { func apiUnpair(id string) error { stream := streams.Get(id) if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } rawURL := findHomeKitURL(stream.Sources()) diff --git a/internal/http/http.go b/internal/http/http.go index 4b0560c1a..380683c52 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -114,7 +114,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) { dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/mjpeg/mjpeg.go b/internal/mjpeg/mjpeg.go index e9f973aa4..f9bf11ff9 100644 --- a/internal/mjpeg/mjpeg.go +++ b/internal/mjpeg/mjpeg.go @@ -40,7 +40,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() stream, _ := streams.GetOrPatch(query) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -139,7 +139,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -174,7 +174,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -193,7 +193,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { func handlerWS(tr *ws.Transport, _ *ws.Message) error { stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } cons := mjpeg.NewConsumer() @@ -219,7 +219,7 @@ func apiStreamY4M(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index d0a6d9717..40354324f 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -43,7 +43,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { src := query.Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -93,7 +93,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { stream, _ := streams.GetOrPatch(query) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/mp4/ws.go b/internal/mp4/ws.go index c1afac244..f628fb68b 100644 --- a/internal/mp4/ws.go +++ b/internal/mp4/ws.go @@ -3,7 +3,6 @@ package mp4 import ( "errors" - "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -13,7 +12,7 @@ import ( func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } var medias []*core.Media @@ -45,7 +44,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } var medias []*core.Media diff --git a/internal/mpeg/mpeg.go b/internal/mpeg/mpeg.go index 0a55299d7..6396b58eb 100644 --- a/internal/mpeg/mpeg.go +++ b/internal/mpeg/mpeg.go @@ -26,7 +26,7 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -49,7 +49,7 @@ func inputMpegTS(w http.ResponseWriter, r *http.Request) { dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -72,7 +72,7 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/rtmp/rtmp.go b/internal/rtmp/rtmp.go index b3d7f9324..ebedd246b 100644 --- a/internal/rtmp/rtmp.go +++ b/internal/rtmp/rtmp.go @@ -155,7 +155,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -179,7 +179,7 @@ func inputFLV(w http.ResponseWriter, r *http.Request) { dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/streams/stream.go b/internal/streams/stream.go index 984c73edd..c7454029e 100644 --- a/internal/streams/stream.go +++ b/internal/streams/stream.go @@ -8,6 +8,8 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) +const StreamNotFound = "stream not found" + type Stream struct { producers []*Producer consumers []core.Consumer diff --git a/internal/webrtc/server.go b/internal/webrtc/server.go index 48bd53802..1dc3e8f59 100644 --- a/internal/webrtc/server.go +++ b/internal/webrtc/server.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" @@ -66,7 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) { u := r.URL.Query().Get("src") stream := streams.Get(u) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } @@ -167,7 +166,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) { dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { - http.Error(w, api.StreamNotFound, http.StatusNotFound) + http.Error(w, streams.StreamNotFound, http.StatusNotFound) return } diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 2a5b4ad66..b78d0efb8 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -130,7 +130,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) { } if stream == nil { - return errors.New(api.StreamNotFound) + return errors.New(streams.StreamNotFound) } var offer struct { diff --git a/internal/webtorrent/init.go b/internal/webtorrent/init.go index b1c25c767..dc4ccec07 100644 --- a/internal/webtorrent/init.go +++ b/internal/webtorrent/init.go @@ -45,7 +45,7 @@ func Init() { Exchange: func(src, offer string) (answer string, err error) { stream := streams.Get(src) if stream == nil { - return "", errors.New(api.StreamNotFound) + return "", errors.New(streams.StreamNotFound) } return webrtc.ExchangeSDP(stream, offer, "webtorrent", "") }, From 97b115c4a9dd295af0db2545f9c1f0f9295f91a8 Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 13:09:11 -0500 Subject: [PATCH 2/7] refactor(api): move shared api server control methods to 'api/server' By moving these to a separate package, the other packages can refer to it, which leaves the original api package only as handlers for the top-level APIs. This also allows for those handlers to refer to the other packages. --- internal/alsa/alsa_linux.go | 2 +- internal/api/api.go | 253 +------------------ internal/api/config.go | 3 +- internal/api/server/handle.go | 21 ++ internal/api/server/response.go | 54 ++++ internal/api/server/server.go | 179 +++++++++++++ internal/api/server/source.go | 9 + internal/api/{ => server}/static.go | 2 +- internal/api/ws/ws.go | 2 +- internal/debug/debug.go | 2 +- internal/debug/stack.go | 2 +- internal/dvrip/dvrip.go | 2 +- internal/ffmpeg/device/device_bsd.go | 2 +- internal/ffmpeg/device/device_darwin.go | 2 +- internal/ffmpeg/device/device_unix.go | 2 +- internal/ffmpeg/device/device_windows.go | 2 +- internal/ffmpeg/device/devices.go | 2 +- internal/ffmpeg/ffmpeg.go | 2 +- internal/ffmpeg/hardware/hardware.go | 2 +- internal/ffmpeg/hardware/hardware_bsd.go | 2 +- internal/ffmpeg/hardware/hardware_darwin.go | 2 +- internal/ffmpeg/hardware/hardware_unix.go | 2 +- internal/ffmpeg/hardware/hardware_windows.go | 2 +- internal/gopro/gopro.go | 2 +- internal/hass/api.go | 2 +- internal/hass/hass.go | 2 +- internal/hls/hls.go | 2 +- internal/homekit/api.go | 2 +- internal/homekit/homekit.go | 2 +- internal/http/http.go | 2 +- internal/mjpeg/mjpeg.go | 2 +- internal/mp4/mp4.go | 2 +- internal/mpeg/mpeg.go | 2 +- internal/nest/init.go | 2 +- internal/onvif/onvif.go | 2 +- internal/ring/ring.go | 2 +- internal/roborock/roborock.go | 2 +- internal/rtmp/rtmp.go | 2 +- internal/streams/api.go | 2 +- internal/streams/streams.go | 2 +- internal/tuya/tuya.go | 2 +- internal/v4l2/v4l2_linux.go | 2 +- internal/webrtc/webrtc.go | 2 +- internal/webtorrent/init.go | 2 +- internal/wyze/wyze.go | 2 +- internal/xiaomi/xiaomi.go | 2 +- 46 files changed, 315 insertions(+), 284 deletions(-) create mode 100644 internal/api/server/handle.go create mode 100644 internal/api/server/response.go create mode 100644 internal/api/server/server.go create mode 100644 internal/api/server/source.go rename internal/api/{ => server}/static.go (97%) diff --git a/internal/alsa/alsa_linux.go b/internal/alsa/alsa_linux.go index 316a7594e..09e2408ff 100644 --- a/internal/alsa/alsa_linux.go +++ b/internal/alsa/alsa_linux.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/alsa" "github.com/AlexxIT/go2rtc/pkg/alsa/device" diff --git a/internal/api/api.go b/internal/api/api.go index 7c919568b..b02756cfe 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,236 +1,31 @@ package api import ( - "crypto/tls" - "encoding/json" - "fmt" - "net" "net/http" "os" - "slices" "strconv" - "strings" "sync" "syscall" - "time" + "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/rs/zerolog" ) func Init() { - var cfg struct { - Mod struct { - Listen string `yaml:"listen"` - Username string `yaml:"username"` - Password string `yaml:"password"` - LocalAuth bool `yaml:"local_auth"` - BasePath string `yaml:"base_path"` - StaticDir string `yaml:"static_dir"` - Origin string `yaml:"origin"` - TLSListen string `yaml:"tls_listen"` - TLSCert string `yaml:"tls_cert"` - TLSKey string `yaml:"tls_key"` - UnixListen string `yaml:"unix_listen"` - - AllowPaths []string `yaml:"allow_paths"` - } `yaml:"api"` - } - - // default config - cfg.Mod.Listen = ":1984" - - // load config from YAML - app.LoadConfig(&cfg) - - if cfg.Mod.Listen == "" && cfg.Mod.UnixListen == "" && cfg.Mod.TLSListen == "" { - return - } - - allowPaths = cfg.Mod.AllowPaths - basePath = cfg.Mod.BasePath + server.Init() log = app.GetLogger("api") - initStatic(cfg.Mod.StaticDir) - - HandleFunc("api", apiHandler) - HandleFunc("api/config", configHandler) - HandleFunc("api/exit", exitHandler) - HandleFunc("api/restart", restartHandler) - HandleFunc("api/log", logHandler) - - Handler = http.DefaultServeMux // 4th - - if cfg.Mod.Origin == "*" { - Handler = middlewareCORS(Handler) // 3rd - } - - if cfg.Mod.Username != "" { - Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd - } - - if log.Trace().Enabled() { - Handler = middlewareLog(Handler) // 1st - } - - if cfg.Mod.Listen != "" { - _, port, _ := net.SplitHostPort(cfg.Mod.Listen) - Port, _ = strconv.Atoi(port) - go listen("tcp", cfg.Mod.Listen) - } - - if cfg.Mod.UnixListen != "" { - _ = syscall.Unlink(cfg.Mod.UnixListen) - go listen("unix", cfg.Mod.UnixListen) - } - - // Initialize the HTTPS server - if cfg.Mod.TLSListen != "" && cfg.Mod.TLSCert != "" && cfg.Mod.TLSKey != "" { - go tlsListen("tcp", cfg.Mod.TLSListen, cfg.Mod.TLSCert, cfg.Mod.TLSKey) - } + server.HandleFunc("api", apiHandler) + server.HandleFunc("api/config", configHandler) + server.HandleFunc("api/exit", exitHandler) + server.HandleFunc("api/restart", restartHandler) + server.HandleFunc("api/log", logHandler) } -func listen(network, address string) { - ln, err := net.Listen(network, address) - if err != nil { - log.Error().Err(err).Msg("[api] listen") - return - } - - log.Info().Str("addr", address).Msg("[api] listen") - - server := http.Server{ - Handler: Handler, - ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds - } - if err = server.Serve(ln); err != nil { - log.Fatal().Err(err).Msg("[api] serve") - } -} - -func tlsListen(network, address, certFile, keyFile string) { - var cert tls.Certificate - var err error - if strings.IndexByte(certFile, '\n') < 0 && strings.IndexByte(keyFile, '\n') < 0 { - // check if file path - cert, err = tls.LoadX509KeyPair(certFile, keyFile) - } else { - // if text file content - cert, err = tls.X509KeyPair([]byte(certFile), []byte(keyFile)) - } - if err != nil { - log.Error().Err(err).Caller().Send() - return - } - - ln, err := net.Listen(network, address) - if err != nil { - log.Error().Err(err).Msg("[api] tls listen") - return - } - - log.Info().Str("addr", address).Msg("[api] tls listen") - - server := &http.Server{ - Handler: Handler, - TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, - ReadHeaderTimeout: 5 * time.Second, - } - if err = server.ServeTLS(ln, "", ""); err != nil { - log.Fatal().Err(err).Msg("[api] tls serve") - } -} - -var Port int - -const ( - MimeJSON = "application/json" - MimeText = "text/plain" -) - -var Handler http.Handler - -// HandleFunc handle pattern with relative path: -// - "api/streams" => "{basepath}/api/streams" -// - "/streams" => "/streams" -func HandleFunc(pattern string, handler http.HandlerFunc) { - if len(pattern) == 0 || pattern[0] != '/' { - pattern = basePath + "/" + pattern - } - if allowPaths != nil && !slices.Contains(allowPaths, pattern) { - log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths") - return - } - log.Trace().Str("path", pattern).Msg("[api] register path") - http.HandleFunc(pattern, handler) -} - -// ResponseJSON important always add Content-Type -// so go won't need to call http.DetectContentType -func ResponseJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", MimeJSON) - _ = json.NewEncoder(w).Encode(v) -} - -func ResponsePrettyJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", MimeJSON) - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - _ = enc.Encode(v) -} - -func Response(w http.ResponseWriter, body any, contentType string) { - w.Header().Set("Content-Type", contentType) - - switch v := body.(type) { - case []byte: - _, _ = w.Write(v) - case string: - _, _ = w.Write([]byte(v)) - default: - _, _ = fmt.Fprint(w, body) - } -} - -var allowPaths []string -var basePath string var log zerolog.Logger -func middlewareLog(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr) - next.ServeHTTP(w, r) - }) -} - -func isLoopback(remoteAddr string) bool { - return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@" -} - -func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if localAuth || !isLoopback(r.RemoteAddr) { - user, pass, ok := r.BasicAuth() - if !ok || user != username || pass != password { - w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - } - - next.ServeHTTP(w, r) - }) -} - -func middlewareCORS(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") - next.ServeHTTP(w, r) - }) -} - var mu sync.Mutex func apiHandler(w http.ResponseWriter, r *http.Request) { @@ -238,7 +33,7 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { app.Info["host"] = r.Host mu.Unlock() - ResponseJSON(w, app.Info) + server.ResponseJSON(w, app.Info) } func exitHandler(w http.ResponseWriter, r *http.Request) { @@ -284,36 +79,8 @@ func logHandler(w http.ResponseWriter, r *http.Request) { _, _ = app.MemoryLog.WriteTo(w) case "DELETE": app.MemoryLog.Reset() - Response(w, "OK", "text/plain") + server.Response(w, "OK", "text/plain") default: http.Error(w, "Method not allowed", http.StatusBadRequest) } } - -type Source struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Info string `json:"info,omitempty"` - URL string `json:"url,omitempty"` - Location string `json:"location,omitempty"` -} - -func ResponseSources(w http.ResponseWriter, sources []*Source) { - if len(sources) == 0 { - http.Error(w, "no sources", http.StatusNotFound) - return - } - - var response = struct { - Sources []*Source `json:"sources"` - }{ - Sources: sources, - } - ResponseJSON(w, response) -} - -func Error(w http.ResponseWriter, err error) { - log.Error().Err(err).Caller(1).Send() - - http.Error(w, err.Error(), http.StatusInsufficientStorage) -} diff --git a/internal/api/config.go b/internal/api/config.go index 9072e8d35..dc9e7e45e 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -5,6 +5,7 @@ import ( "net/http" "os" + "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "gopkg.in/yaml.v3" ) @@ -23,7 +24,7 @@ func configHandler(w http.ResponseWriter, r *http.Request) { return } // https://www.ietf.org/archive/id/draft-ietf-httpapi-yaml-mediatypes-00.html - Response(w, data, "application/yaml") + server.Response(w, data, "application/yaml") case "POST", "PATCH": data, err := io.ReadAll(r.Body) diff --git a/internal/api/server/handle.go b/internal/api/server/handle.go new file mode 100644 index 000000000..b13d80bd9 --- /dev/null +++ b/internal/api/server/handle.go @@ -0,0 +1,21 @@ +package server + +import ( + "net/http" + "slices" +) + +// HandleFunc handle pattern with relative path: +// - "api/streams" => "{basepath}/api/streams" +// - "/streams" => "/streams" +func HandleFunc(pattern string, handler http.HandlerFunc) { + if len(pattern) == 0 || pattern[0] != '/' { + pattern = basePath + "/" + pattern + } + if allowPaths != nil && !slices.Contains(allowPaths, pattern) { + log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths") + return + } + log.Trace().Str("path", pattern).Msg("[api] register path") + http.HandleFunc(pattern, handler) +} diff --git a/internal/api/server/response.go b/internal/api/server/response.go new file mode 100644 index 000000000..66e7a4903 --- /dev/null +++ b/internal/api/server/response.go @@ -0,0 +1,54 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// ResponseJSON important always add Content-Type +// so go won't need to call http.DetectContentType +func ResponseJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", MimeJSON) + _ = json.NewEncoder(w).Encode(v) +} + +func ResponsePrettyJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", MimeJSON) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func Response(w http.ResponseWriter, body any, contentType string) { + w.Header().Set("Content-Type", contentType) + + switch v := body.(type) { + case []byte: + _, _ = w.Write(v) + case string: + _, _ = w.Write([]byte(v)) + default: + _, _ = fmt.Fprint(w, body) + } +} + +func ResponseSources(w http.ResponseWriter, sources []*Source) { + if len(sources) == 0 { + http.Error(w, "no sources", http.StatusNotFound) + return + } + + var response = struct { + Sources []*Source `json:"sources"` + }{ + Sources: sources, + } + ResponseJSON(w, response) +} + +func Error(w http.ResponseWriter, err error) { + log.Error().Err(err).Caller(1).Send() + + http.Error(w, err.Error(), http.StatusInsufficientStorage) +} diff --git a/internal/api/server/server.go b/internal/api/server/server.go new file mode 100644 index 000000000..c6de9a541 --- /dev/null +++ b/internal/api/server/server.go @@ -0,0 +1,179 @@ +package server + +import ( + "crypto/tls" + "net" + "net/http" + "strconv" + "strings" + "syscall" + "time" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + Username string `yaml:"username"` + Password string `yaml:"password"` + LocalAuth bool `yaml:"local_auth"` + BasePath string `yaml:"base_path"` + StaticDir string `yaml:"static_dir"` + Origin string `yaml:"origin"` + TLSListen string `yaml:"tls_listen"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + UnixListen string `yaml:"unix_listen"` + + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"api"` + } + + // default config + cfg.Mod.Listen = ":1984" + + // load config from YAML + app.LoadConfig(&cfg) + + if cfg.Mod.Listen == "" && cfg.Mod.UnixListen == "" && cfg.Mod.TLSListen == "" { + return + } + + allowPaths = cfg.Mod.AllowPaths + basePath = cfg.Mod.BasePath + log = app.GetLogger("api") + + initStatic(cfg.Mod.StaticDir) + + Handler = http.DefaultServeMux // 4th + + if cfg.Mod.Origin == "*" { + Handler = middlewareCORS(Handler) // 3rd + } + + if cfg.Mod.Username != "" { + Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd + } + + if log.Trace().Enabled() { + Handler = middlewareLog(Handler) // 1st + } + + if cfg.Mod.Listen != "" { + _, port, _ := net.SplitHostPort(cfg.Mod.Listen) + Port, _ = strconv.Atoi(port) + go listen("tcp", cfg.Mod.Listen) + } + + if cfg.Mod.UnixListen != "" { + _ = syscall.Unlink(cfg.Mod.UnixListen) + go listen("unix", cfg.Mod.UnixListen) + } + + // Initialize the HTTPS server + if cfg.Mod.TLSListen != "" && cfg.Mod.TLSCert != "" && cfg.Mod.TLSKey != "" { + go tlsListen("tcp", cfg.Mod.TLSListen, cfg.Mod.TLSCert, cfg.Mod.TLSKey) + } +} + +var Port int + +var Handler http.Handler + +const ( + MimeJSON = "application/json" + MimeText = "text/plain" +) + +var allowPaths []string +var basePath string +var log zerolog.Logger + +func listen(network, address string) { + ln, err := net.Listen(network, address) + if err != nil { + log.Error().Err(err).Msg("[api] listen") + return + } + + log.Info().Str("addr", address).Msg("[api] listen") + + server := http.Server{ + Handler: Handler, + ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds + } + if err = server.Serve(ln); err != nil { + log.Fatal().Err(err).Msg("[api] serve") + } +} + +func tlsListen(network, address, certFile, keyFile string) { + var cert tls.Certificate + var err error + if strings.IndexByte(certFile, '\n') < 0 && strings.IndexByte(keyFile, '\n') < 0 { + // check if file path + cert, err = tls.LoadX509KeyPair(certFile, keyFile) + } else { + // if text file content + cert, err = tls.X509KeyPair([]byte(certFile), []byte(keyFile)) + } + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + ln, err := net.Listen(network, address) + if err != nil { + log.Error().Err(err).Msg("[api] tls listen") + return + } + + log.Info().Str("addr", address).Msg("[api] tls listen") + + server := &http.Server{ + Handler: Handler, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, + ReadHeaderTimeout: 5 * time.Second, + } + if err = server.ServeTLS(ln, "", ""); err != nil { + log.Fatal().Err(err).Msg("[api] tls serve") + } +} + +func middlewareLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr) + next.ServeHTTP(w, r) + }) +} + +func isLoopback(remoteAddr string) bool { + return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@" +} + +func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if localAuth || !isLoopback(r.RemoteAddr) { + user, pass, ok := r.BasicAuth() + if !ok || user != username || pass != password { + w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +func middlewareCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/server/source.go b/internal/api/server/source.go new file mode 100644 index 000000000..e28f8f98d --- /dev/null +++ b/internal/api/server/source.go @@ -0,0 +1,9 @@ +package server + +type Source struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Info string `json:"info,omitempty"` + URL string `json:"url,omitempty"` + Location string `json:"location,omitempty"` +} diff --git a/internal/api/static.go b/internal/api/server/static.go similarity index 97% rename from internal/api/static.go rename to internal/api/server/static.go index 8de400735..22e72106d 100644 --- a/internal/api/static.go +++ b/internal/api/server/static.go @@ -1,4 +1,4 @@ -package api +package server import ( "net/http" diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 02c2f90cc..83a67625b 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/creds" "github.com/gorilla/websocket" diff --git a/internal/debug/debug.go b/internal/debug/debug.go index fc7d2453f..0a83851ef 100644 --- a/internal/debug/debug.go +++ b/internal/debug/debug.go @@ -1,7 +1,7 @@ package debug import ( - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) func Init() { diff --git a/internal/debug/stack.go b/internal/debug/stack.go index 6bc735ad8..fb95ef708 100644 --- a/internal/debug/stack.go +++ b/internal/debug/stack.go @@ -6,7 +6,7 @@ import ( "net/http" "runtime" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) var stackSkip = [][]byte{ diff --git a/internal/dvrip/dvrip.go b/internal/dvrip/dvrip.go index db1c60dbc..546b07671 100644 --- a/internal/dvrip/dvrip.go +++ b/internal/dvrip/dvrip.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/dvrip" ) diff --git a/internal/ffmpeg/device/device_bsd.go b/internal/ffmpeg/device/device_bsd.go index 27d5b6154..4d3828c21 100644 --- a/internal/ffmpeg/device/device_bsd.go +++ b/internal/ffmpeg/device/device_bsd.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/pkg/core" ) diff --git a/internal/ffmpeg/device/device_darwin.go b/internal/ffmpeg/device/device_darwin.go index ba97c0aa9..fa05fd1f9 100644 --- a/internal/ffmpeg/device/device_darwin.go +++ b/internal/ffmpeg/device/device_darwin.go @@ -8,7 +8,7 @@ import ( "regexp" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/pkg/core" ) diff --git a/internal/ffmpeg/device/device_unix.go b/internal/ffmpeg/device/device_unix.go index 7b62187f3..f7192200d 100644 --- a/internal/ffmpeg/device/device_unix.go +++ b/internal/ffmpeg/device/device_unix.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/pkg/core" ) diff --git a/internal/ffmpeg/device/device_windows.go b/internal/ffmpeg/device/device_windows.go index ff3283117..92548548f 100644 --- a/internal/ffmpeg/device/device_windows.go +++ b/internal/ffmpeg/device/device_windows.go @@ -7,7 +7,7 @@ import ( "os/exec" "regexp" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/pkg/core" ) diff --git a/internal/ffmpeg/device/devices.go b/internal/ffmpeg/device/devices.go index 69b134446..3f799314b 100644 --- a/internal/ffmpeg/device/devices.go +++ b/internal/ffmpeg/device/devices.go @@ -6,7 +6,7 @@ import ( "strconv" "sync" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) func Init(bin string) { diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index a3d589b16..a1f2bf15f 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -4,7 +4,7 @@ import ( "net/url" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg/device" "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" diff --git a/internal/ffmpeg/hardware/hardware.go b/internal/ffmpeg/hardware/hardware.go index 801668905..89b6ea7a4 100644 --- a/internal/ffmpeg/hardware/hardware.go +++ b/internal/ffmpeg/hardware/hardware.go @@ -5,7 +5,7 @@ import ( "os/exec" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" ) diff --git a/internal/ffmpeg/hardware/hardware_bsd.go b/internal/ffmpeg/hardware/hardware_bsd.go index de24ac5c8..6e9d5a45e 100644 --- a/internal/ffmpeg/hardware/hardware_bsd.go +++ b/internal/ffmpeg/hardware/hardware_bsd.go @@ -5,7 +5,7 @@ package hardware import ( "runtime" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) const ( diff --git a/internal/ffmpeg/hardware/hardware_darwin.go b/internal/ffmpeg/hardware/hardware_darwin.go index b15055128..dae27fad8 100644 --- a/internal/ffmpeg/hardware/hardware_darwin.go +++ b/internal/ffmpeg/hardware/hardware_darwin.go @@ -3,7 +3,7 @@ package hardware import ( - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2=size=svga -t 1 -c h264_videotoolbox -f null -" diff --git a/internal/ffmpeg/hardware/hardware_unix.go b/internal/ffmpeg/hardware/hardware_unix.go index e8000e17d..7635ebf72 100644 --- a/internal/ffmpeg/hardware/hardware_unix.go +++ b/internal/ffmpeg/hardware/hardware_unix.go @@ -5,7 +5,7 @@ package hardware import ( "runtime" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" ) const ( diff --git a/internal/ffmpeg/hardware/hardware_windows.go b/internal/ffmpeg/hardware/hardware_windows.go index cdf0e12cc..b56b5300d 100644 --- a/internal/ffmpeg/hardware/hardware_windows.go +++ b/internal/ffmpeg/hardware/hardware_windows.go @@ -2,7 +2,7 @@ package hardware -import "github.com/AlexxIT/go2rtc/internal/api" +import api "github.com/AlexxIT/go2rtc/internal/api/server" const ProbeDXVA2H264 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c h264_qsv -f null -" const ProbeDXVA2H265 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c hevc_qsv -f null -" diff --git a/internal/gopro/gopro.go b/internal/gopro/gopro.go index ee5780494..a84c0fc03 100644 --- a/internal/gopro/gopro.go +++ b/internal/gopro/gopro.go @@ -3,7 +3,7 @@ package gopro import ( "net/http" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/gopro" diff --git a/internal/hass/api.go b/internal/hass/api.go index a7a5aa2ab..21f83d32c 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -7,7 +7,7 @@ import ( "net/http" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/webrtc" ) diff --git a/internal/hass/hass.go b/internal/hass/hass.go index 99c636926..73471f038 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 2e4d49af1..381bb1ea9 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/homekit/api.go b/internal/homekit/api.go index cb88193f3..cc0824fcd 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -8,7 +8,7 @@ import ( "net/url" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/hap" diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 59b84b3ba..2025eb230 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/srtp" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/http/http.go b/internal/http/http.go index 380683c52..35259073e 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -7,7 +7,7 @@ import ( "net/url" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hls" diff --git a/internal/mjpeg/mjpeg.go b/internal/mjpeg/mjpeg.go index f9bf11ff9..b7d005779 100644 --- a/internal/mjpeg/mjpeg.go +++ b/internal/mjpeg/mjpeg.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index 40354324f..195cc16da 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/mpeg/mpeg.go b/internal/mpeg/mpeg.go index 6396b58eb..c750149ba 100644 --- a/internal/mpeg/mpeg.go +++ b/internal/mpeg/mpeg.go @@ -3,7 +3,7 @@ package mpeg import ( "net/http" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/mpegts" diff --git a/internal/nest/init.go b/internal/nest/init.go index 8289af73e..36ddad804 100644 --- a/internal/nest/init.go +++ b/internal/nest/init.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/nest" diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index c305b706e..7caee0051 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/rtsp" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 7fdb284f2..26ee78cfd 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -6,7 +6,7 @@ import ( "fmt" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ring" diff --git a/internal/roborock/roborock.go b/internal/roborock/roborock.go index 32a436d86..71c0db4ef 100644 --- a/internal/roborock/roborock.go +++ b/internal/roborock/roborock.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/roborock" diff --git a/internal/rtmp/rtmp.go b/internal/rtmp/rtmp.go index ebedd246b..30bcd60a1 100644 --- a/internal/rtmp/rtmp.go +++ b/internal/rtmp/rtmp.go @@ -6,7 +6,7 @@ import ( "net" "net/http" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" diff --git a/internal/streams/api.go b/internal/streams/api.go index d6142eb02..4cc600d07 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -3,7 +3,7 @@ package streams import ( "net/http" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/creds" diff --git a/internal/streams/streams.go b/internal/streams/streams.go index f3b8df03c..3182e36d2 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/rs/zerolog" ) diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index 9dcf27212..38bc00b2d 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -9,7 +9,7 @@ import ( "net/url" "strconv" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tuya" diff --git a/internal/v4l2/v4l2_linux.go b/internal/v4l2/v4l2_linux.go index 0bb05473e..522a88f5d 100644 --- a/internal/v4l2/v4l2_linux.go +++ b/internal/v4l2/v4l2_linux.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/v4l2" diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index b78d0efb8..20dbc56d2 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -5,7 +5,7 @@ import ( "net" "strings" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" diff --git a/internal/webtorrent/init.go b/internal/webtorrent/init.go index dc4ccec07..791a2d83b 100644 --- a/internal/webtorrent/init.go +++ b/internal/webtorrent/init.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/webrtc" diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go index 982a16edf..6be4ee44f 100644 --- a/internal/wyze/wyze.go +++ b/internal/wyze/wyze.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index 1e578420c..a307c6f52 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -10,7 +10,7 @@ import ( "strings" "sync" - "github.com/AlexxIT/go2rtc/internal/api" + api "github.com/AlexxIT/go2rtc/internal/api/server" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" From bad289e3736ce9a111fc4576a205136aafc6f076 Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 13:11:17 -0500 Subject: [PATCH 3/7] feat(streams): make StopProducers public, support 'force' Optional 'force' argument, when true, results in stopping all producers regardless of tracks, consumers, etc. This provides the application a means to "clean up" active streams. --- internal/streams/add_consumer.go | 2 +- internal/streams/stream.go | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/streams/add_consumer.go b/internal/streams/add_consumer.go index 7400ce6e2..a83c78c35 100644 --- a/internal/streams/add_consumer.go +++ b/internal/streams/add_consumer.go @@ -95,7 +95,7 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { // stop producers if they don't have readers if s.pending.Add(-1) == 0 { - s.stopProducers() + s.StopProducers() } if len(prodStarts) == 0 { diff --git a/internal/streams/stream.go b/internal/streams/stream.go index c7454029e..2f98cea63 100644 --- a/internal/streams/stream.go +++ b/internal/streams/stream.go @@ -75,7 +75,7 @@ func (s *Stream) RemoveConsumer(cons core.Consumer) { } s.mu.Unlock() - s.stopProducers() + s.StopProducers() } func (s *Stream) AddProducer(prod core.Producer) { @@ -96,7 +96,12 @@ func (s *Stream) RemoveProducer(prod core.Producer) { s.mu.Unlock() } -func (s *Stream) stopProducers() { +func (s *Stream) StopProducers(force ...bool) { + // Default force=false, only stop producers without active tracks + // If force=true, stop all producers regardless of active tracks + if len(force) == 0 { + force = append(force, false) + } if s.pending.Load() > 0 { log.Trace().Msg("[streams] skip stop pending producer") return @@ -105,14 +110,16 @@ func (s *Stream) stopProducers() { s.mu.Lock() producers: for _, producer := range s.producers { - for _, track := range producer.receivers { - if len(track.Senders()) > 0 { - continue producers + if !force[0] { + for _, track := range producer.receivers { + if len(track.Senders()) > 0 { + continue producers + } } - } - for _, track := range producer.senders { - if len(track.Senders()) > 0 { - continue producers + for _, track := range producer.senders { + if len(track.Senders()) > 0 { + continue producers + } } } producer.stop() From d180f26ec2a3827811f98789f6f8eee0cd7e00dc Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 13:14:30 -0500 Subject: [PATCH 4/7] feat(streams): added StopAll method This method iterates over all the stream instances and calls StopProducers(true) to force stopping all producers in every stream as a means to "clean up" those connections, if necessary. --- internal/streams/streams.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 3182e36d2..2a0df91b2 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -174,3 +174,11 @@ func GetAllSources() map[string][]string { streamsMu.Unlock() return sources } + +func StopAll() { + streamsMu.Lock() + for _, stream := range streams { + stream.StopProducers(true) + } + streamsMu.Unlock() +} From a1b5e7572dfa70e25509a3b947c3f4310e8552d5 Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 13:15:40 -0500 Subject: [PATCH 5/7] feat(api): 'exit' now stops all producers (clean up) By allowing all producers to stop, modules like rtsp will send 'TEARDOWN' messages to the remote device, allowing that remote device to free up any resources utilized by go2rtc to provide the stream during configuration. --- internal/api/api.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index b02756cfe..40945e7ce 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -51,6 +51,9 @@ func exitHandler(w http.ResponseWriter, r *http.Request) { return } + // Stop all streams before exiting. + streams.StopAll() + os.Exit(code) } From b5ea484cfa50e8f49b772b1f6884f5787d409d5c Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 14:08:33 -0500 Subject: [PATCH 6/7] feat(exec): add 'quitstdin' as a query arg This allows us to pass strings/characters to stdin for exec processes that can take a more graceful end. --- internal/exec/README.md | 1 + internal/exec/exec.go | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/internal/exec/README.md b/internal/exec/README.md index aba1c5553..6b2b5ed82 100644 --- a/internal/exec/README.md +++ b/internal/exec/README.md @@ -20,6 +20,7 @@ Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): - `killsignal` - signal which will be sent to stop the process (numeric form) - `killtimeout` - time in seconds for forced termination with sigkill +- `quitstdin` - string to pass on stdin for graceful termination - `backchannel` - enable backchannel for two-way audio - `starttimeout` - time in seconds for waiting first byte from RTSP diff --git a/internal/exec/exec.go b/internal/exec/exec.go index e428aefbe..56e1bbabe 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -2,6 +2,7 @@ package exec import ( "bufio" + "bytes" "crypto/md5" "encoding/hex" "errors" @@ -104,6 +105,12 @@ func execHandle(rawURL string) (prod core.Producer, err error) { cmd.WaitDelay = time.Duration(core.Atoi(s)) * time.Second } + if s := query.Get("quitstdin"); s != "" { + buffer := bytes.Buffer{} + buffer.Write([]byte(s + "\n")) + cmd.Stdin = &buffer + } + if query.Get("backchannel") == "1" { return pcm.NewBackchannel(cmd, query.Get("audio")) } From 1968f6671fc87b5d6745ca2ed321e86bf6f7a8f2 Mon Sep 17 00:00:00 2001 From: Thomas Goodwin Date: Wed, 25 Feb 2026 14:09:16 -0500 Subject: [PATCH 7/7] feat(ffmpeg): append query with 'quitstdin=q' for graceful stop This lets ffmpeg quit via passing 'q' to stdin, which is what it expects when run as a shell process. --- internal/ffmpeg/producer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index fb0444672..d2e4f43aa 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -31,6 +31,10 @@ func NewProducer(url string) (core.Producer, error) { return nil, errors.New("ffmpeg: unsupported params: " + url[i:]) } + // Append quitstdin param to ensure ffmpeg process is terminated when the producer is stopped. + // This is necessary as it lets ffmpeg exit gracefully. + p.query.Add("quitstdin", "q") + p.ID = core.NewID() p.FormatName = "ffmpeg" p.Medias = []*core.Media{