From cc453dd9ed84c354917ccd7057b14eb35b2c694d Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 1 Feb 2026 04:13:43 +0300 Subject: [PATCH 1/3] feat: add read-only mode to API and UI, disable write actions --- README.md | 1 + internal/api/api.go | 27 ++++++++ internal/api/config.go | 4 ++ internal/api/config_readonly_test.go | 36 +++++++++++ internal/app/config.go | 4 ++ internal/app/config_readonly_test.go | 29 +++++++++ internal/ffmpeg/api.go | 5 ++ internal/ffmpeg/api_readonly_test.go | 26 ++++++++ internal/homekit/api.go | 4 ++ internal/homekit/api_readonly_test.go | 37 +++++++++++ internal/roborock/roborock.go | 4 ++ internal/roborock/roborock_readonly_test.go | 26 ++++++++ internal/streams/api.go | 14 +++++ internal/streams/api_readonly_test.go | 68 +++++++++++++++++++++ internal/wyze/wyze.go | 4 ++ internal/wyze/wyze_readonly_test.go | 26 ++++++++ internal/xiaomi/xiaomi.go | 4 ++ internal/xiaomi/xiaomi_readonly_test.go | 26 ++++++++ www/add.html | 18 +++++- www/config.html | 15 ++++- www/index.html | 27 ++++++-- www/links.html | 15 +++++ www/main.js | 21 +++++++ www/schema.json | 5 ++ 24 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 internal/api/config_readonly_test.go create mode 100644 internal/app/config_readonly_test.go create mode 100644 internal/ffmpeg/api_readonly_test.go create mode 100644 internal/homekit/api_readonly_test.go create mode 100644 internal/roborock/roborock_readonly_test.go create mode 100644 internal/streams/api_readonly_test.go create mode 100644 internal/wyze/wyze_readonly_test.go create mode 100644 internal/xiaomi/xiaomi_readonly_test.go diff --git a/README.md b/README.md index 5366f8be..84bd85a8 100644 --- a/README.md +++ b/README.md @@ -965,6 +965,7 @@ api: listen: ":1984" # default ":1984", HTTP API port ("" - disabled) username: "admin" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI + read_only: true # default false, Disable write actions in WebUI/API local_auth: true # default false, Enable auth check for localhost requests base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) static_dir: "www" # default "", folder for static files (custom web interface) diff --git a/internal/api/api.go b/internal/api/api.go index dfb65117..40652b96 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -32,6 +32,7 @@ func Init() { TLSCert string `yaml:"tls_cert"` TLSKey string `yaml:"tls_key"` UnixListen string `yaml:"unix_listen"` + ReadOnly bool `yaml:"read_only"` AllowPaths []string `yaml:"allow_paths"` } `yaml:"api"` @@ -50,6 +51,9 @@ func Init() { allowPaths = cfg.Mod.AllowPaths basePath = cfg.Mod.BasePath log = app.GetLogger("api") + ReadOnly = cfg.Mod.ReadOnly + app.ConfigReadOnly = ReadOnly + app.Info["read_only"] = ReadOnly initStatic(cfg.Mod.StaticDir) @@ -149,6 +153,15 @@ const ( ) var Handler http.Handler +var ReadOnly bool + +func IsReadOnly() bool { + return ReadOnly +} + +func ReadOnlyError(w http.ResponseWriter) { + http.Error(w, "read-only", http.StatusForbidden) +} // HandleFunc handle pattern with relative path: // - "api/streams" => "{basepath}/api/streams" @@ -249,6 +262,11 @@ func exitHandler(w http.ResponseWriter, r *http.Request) { return } + if IsReadOnly() { + ReadOnlyError(w) + return + } + s := r.URL.Query().Get("code") code, err := strconv.Atoi(s) @@ -267,6 +285,11 @@ func restartHandler(w http.ResponseWriter, r *http.Request) { return } + if IsReadOnly() { + ReadOnlyError(w) + return + } + path, err := os.Executable() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -285,6 +308,10 @@ func logHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/jsonlines") _, _ = app.MemoryLog.WriteTo(w) case "DELETE": + if IsReadOnly() { + ReadOnlyError(w) + return + } app.MemoryLog.Reset() Response(w, "OK", "text/plain") default: diff --git a/internal/api/config.go b/internal/api/config.go index 9072e8d3..373b722b 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -26,6 +26,10 @@ func configHandler(w http.ResponseWriter, r *http.Request) { Response(w, data, "application/yaml") case "POST", "PATCH": + if IsReadOnly() { + ReadOnlyError(w) + return + } data, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/internal/api/config_readonly_test.go b/internal/api/config_readonly_test.go new file mode 100644 index 00000000..ad466d0e --- /dev/null +++ b/internal/api/config_readonly_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/stretchr/testify/require" +) + +func TestConfigHandlerReadOnly(t *testing.T) { + prevPath := app.ConfigPath + prevReadOnly := ReadOnly + t.Cleanup(func() { + app.ConfigPath = prevPath + ReadOnly = prevReadOnly + }) + + app.ConfigPath = filepath.Join(t.TempDir(), "config.yaml") + ReadOnly = true + + for _, method := range []string{"POST", "PATCH"} { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/config", strings.NewReader("log:\n level: info\n")) + w := httptest.NewRecorder() + + configHandler(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "read-only") + }) + } +} diff --git a/internal/app/config.go b/internal/app/config.go index 0f95894a..79322a83 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -20,11 +20,15 @@ func LoadConfig(v any) { } var configMu sync.Mutex +var ConfigReadOnly bool func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") } + if ConfigReadOnly { + return errors.New("config is read-only") + } configMu.Lock() defer configMu.Unlock() diff --git a/internal/app/config_readonly_test.go b/internal/app/config_readonly_test.go new file mode 100644 index 00000000..57068359 --- /dev/null +++ b/internal/app/config_readonly_test.go @@ -0,0 +1,29 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPatchConfigReadOnly(t *testing.T) { + prevPath := ConfigPath + prevReadOnly := ConfigReadOnly + t.Cleanup(func() { + ConfigPath = prevPath + ConfigReadOnly = prevReadOnly + }) + + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(""), 0644)) + + ConfigPath = path + ConfigReadOnly = true + + err := PatchConfig([]string{"streams", "cam"}, "rtsp://example.com") + require.Error(t, err) + require.EqualError(t, err, "config is read-only") +} diff --git a/internal/ffmpeg/api.go b/internal/ffmpeg/api.go index d802f87c..85e7d092 100644 --- a/internal/ffmpeg/api.go +++ b/internal/ffmpeg/api.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" ) @@ -12,6 +13,10 @@ func apiFFmpeg(w http.ResponseWriter, r *http.Request) { http.Error(w, "", http.StatusMethodNotAllowed) return } + if api.IsReadOnly() { + api.ReadOnlyError(w) + return + } query := r.URL.Query() dst := query.Get("dst") diff --git a/internal/ffmpeg/api_readonly_test.go b/internal/ffmpeg/api_readonly_test.go new file mode 100644 index 00000000..ec8a4a3c --- /dev/null +++ b/internal/ffmpeg/api_readonly_test.go @@ -0,0 +1,26 @@ +package ffmpeg + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiFFmpegReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + req := httptest.NewRequest("POST", "/api/ffmpeg?dst=cam&text=hello", nil) + w := httptest.NewRecorder() + + apiFFmpeg(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/internal/homekit/api.go b/internal/homekit/api.go index 885a40fa..e96ed6ce 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -43,6 +43,10 @@ func apiDiscovery(w http.ResponseWriter, r *http.Request) { } func apiHomekit(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() && r.Method != "GET" { + api.ReadOnlyError(w) + return + } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/internal/homekit/api_readonly_test.go b/internal/homekit/api_readonly_test.go new file mode 100644 index 00000000..95e2dbf7 --- /dev/null +++ b/internal/homekit/api_readonly_test.go @@ -0,0 +1,37 @@ +package homekit + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiHomekitReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + t.Run("POST blocked", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/homekit", nil) + w := httptest.NewRecorder() + + apiHomekit(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + }) + + t.Run("GET allowed", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/homekit", nil) + w := httptest.NewRecorder() + + apiHomekit(w, req) + + require.Equal(t, http.StatusOK, w.Code) + }) +} diff --git a/internal/roborock/roborock.go b/internal/roborock/roborock.go index 32a436d8..07bf5dc0 100644 --- a/internal/roborock/roborock.go +++ b/internal/roborock/roborock.go @@ -24,6 +24,10 @@ var Auth struct { } func apiHandle(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() && r.Method != "GET" { + api.ReadOnlyError(w) + return + } switch r.Method { case "GET": if Auth.UserData == nil { diff --git a/internal/roborock/roborock_readonly_test.go b/internal/roborock/roborock_readonly_test.go new file mode 100644 index 00000000..d7ae0a38 --- /dev/null +++ b/internal/roborock/roborock_readonly_test.go @@ -0,0 +1,26 @@ +package roborock + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiHandleReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + req := httptest.NewRequest("POST", "/api/roborock", nil) + w := httptest.NewRecorder() + + apiHandle(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/internal/streams/api.go b/internal/streams/api.go index d6142eb0..a15bcfea 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -12,6 +12,13 @@ import ( func apiStreams(w http.ResponseWriter, r *http.Request) { w = creds.SecretResponse(w) + if api.IsReadOnly() { + switch r.Method { + case "PUT", "PATCH", "POST", "DELETE": + api.ReadOnlyError(w) + return + } + } query := r.URL.Query() src := query.Get("src") @@ -130,6 +137,13 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } func apiPreload(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() { + switch r.Method { + case "PUT", "DELETE": + api.ReadOnlyError(w) + return + } + } // GET - return all preloads if r.Method == "GET" { api.ResponseJSON(w, GetPreloads()) diff --git a/internal/streams/api_readonly_test.go b/internal/streams/api_readonly_test.go new file mode 100644 index 00000000..c5b5c80a --- /dev/null +++ b/internal/streams/api_readonly_test.go @@ -0,0 +1,68 @@ +package streams + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiStreamsReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/streams?src=test", nil) + w := httptest.NewRecorder() + + apiStreams(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + }) + } + + t.Run("GET allowed", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/streams", nil) + w := httptest.NewRecorder() + + apiStreams(w, req) + + require.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestApiPreloadReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + for _, method := range []string{"PUT", "DELETE"} { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/preload?src=test", nil) + w := httptest.NewRecorder() + + apiPreload(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + }) + } + + t.Run("GET allowed", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/preload", nil) + w := httptest.NewRecorder() + + apiPreload(w, req) + + require.Equal(t, http.StatusOK, w.Code) + }) +} diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go index 982a16ed..ae9a4b6b 100644 --- a/internal/wyze/wyze.go +++ b/internal/wyze/wyze.go @@ -59,6 +59,10 @@ func getCloud(email string) (*wyze.Cloud, error) { } func apiWyze(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() && r.Method != "GET" { + api.ReadOnlyError(w) + return + } switch r.Method { case "GET": apiDeviceList(w, r) diff --git a/internal/wyze/wyze_readonly_test.go b/internal/wyze/wyze_readonly_test.go new file mode 100644 index 00000000..1a4d9fb4 --- /dev/null +++ b/internal/wyze/wyze_readonly_test.go @@ -0,0 +1,26 @@ +package wyze + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiWyzeReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + req := httptest.NewRequest("POST", "/api/wyze", nil) + w := httptest.NewRecorder() + + apiWyze(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index a5b23420..333fb01d 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -219,6 +219,10 @@ func wakeUpCamera(url *url.URL) error { } func apiXiaomi(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() && r.Method != "GET" { + api.ReadOnlyError(w) + return + } switch r.Method { case "GET": apiDeviceList(w, r) diff --git a/internal/xiaomi/xiaomi_readonly_test.go b/internal/xiaomi/xiaomi_readonly_test.go new file mode 100644 index 00000000..252f44ae --- /dev/null +++ b/internal/xiaomi/xiaomi_readonly_test.go @@ -0,0 +1,26 @@ +package xiaomi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiXiaomiReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + req := httptest.NewRequest("POST", "/api/xiaomi", nil) + w := httptest.NewRecorder() + + apiXiaomi(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/www/add.html b/www/add.html index a2e0d85f..ff8fcbb8 100644 --- a/www/add.html +++ b/www/add.html @@ -52,8 +52,24 @@ drawTable(table, await r.json()); } +
+
@@ -566,4 +582,4 @@
- \ No newline at end of file + diff --git a/www/config.html b/www/config.html index 026b5beb..c87a6a8f 100644 --- a/www/config.html +++ b/www/config.html @@ -1182,7 +1182,20 @@ let dump; - document.getElementById('save').addEventListener('click', async () => { + const saveButton = document.getElementById('save'); + const applyReadOnly = () => { + saveButton.disabled = true; + saveButton.title = 'Read-only mode'; + editor.updateOptions({readOnly: true}); + }; + + if (window.go2rtcReady) { + window.go2rtcReady.then(data => { + if (data && data.read_only) applyReadOnly(); + }); + } + + saveButton.addEventListener('click', async () => { let r = await fetch('api/config', {cache: 'no-cache'}); if (r.ok && dump !== await r.text()) { alert('Config was changed from another place. Refresh the page and make changes again'); diff --git a/www/index.html b/www/index.html index 69126e6f..13d1bac3 100644 --- a/www/index.html +++ b/www/index.html @@ -45,11 +45,18 @@ diff --git a/www/links.html b/www/links.html index 13e08edf..d942e9bc 100644 --- a/www/links.html +++ b/www/links.html @@ -152,6 +152,21 @@ Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx fetch(url, {method: 'POST'}); }); +

WebRTC Magic

diff --git a/www/main.js b/www/main.js index d5629178..55c12456 100644 --- a/www/main.js +++ b/www/main.js @@ -133,3 +133,24 @@ document.body.innerHTML = ` ` + document.body.innerHTML; + +window.go2rtcReady = (async () => { + try { + const url = new URL('api', location.href); + const r = await fetch(url, {cache: 'no-cache'}); + if (!r.ok) return null; + const data = await r.json(); + window.go2rtcInfo = data; + return data; + } catch (e) { + return null; + } +})(); + +window.go2rtcReady.then(data => { + if (!data || !data.read_only) return; + const links = document.querySelectorAll('nav a[href="add.html"], nav a[href="config.html"]'); + links.forEach(link => { + link.style.display = 'none'; + }); +}); diff --git a/www/schema.json b/www/schema.json index d9c87e40..ebec9f44 100644 --- a/www/schema.json +++ b/www/schema.json @@ -68,6 +68,11 @@ "password": { "type": "string" }, + "read_only": { + "description": "Disable write actions in WebUI/API", + "type": "boolean", + "default": false + }, "local_auth": { "description": "Enable auth check for localhost requests", "type": "boolean", From 51b79e614f95d773fa06378f84c485099339d493 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 1 Feb 2026 04:38:48 +0300 Subject: [PATCH 2/3] feat: enhance read-only mode with confirmation and server polling on save --- www/config.html | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/www/config.html b/www/config.html index c87a6a8f..7bfa2475 100644 --- a/www/config.html +++ b/www/config.html @@ -1183,6 +1183,11 @@ let dump; const saveButton = document.getElementById('save'); + const readOnlyWarning = 'Enabling read_only: true cannot be reverted remotely. To disable it you must edit the config file on the server manually. Continue?'; + const hasReadOnlyTrue = (text) => { + if (!text) return false; + return /(^|\n)\s*read_only\s*:\s*true\s*(#.*)?$/m.test(text); + }; const applyReadOnly = () => { saveButton.disabled = true; saveButton.title = 'Read-only mode'; @@ -1202,11 +1207,36 @@ return; } - r = await fetch('api/config', {method: 'POST', body: editor.getValue()}); + const nextValue = editor.getValue(); + if (hasReadOnlyTrue(nextValue) && !hasReadOnlyTrue(dump)) { + if (!confirm(readOnlyWarning)) return; + } + + r = await fetch('api/config', {method: 'POST', body: nextValue}); if (r.ok) { alert('OK'); - dump = editor.getValue(); + dump = nextValue; await fetch('api/restart', {method: 'POST'}); + + // Poll server until it's back online before reloading + const waitForServer = async () => { + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 500)); + try { + const response = await fetch('api/config', {cache: 'no-cache'}); + if (response.ok || response.status === 410) { + location.reload(); + return; + } + } catch (e) { + // Server still down, continue polling + } + } + // Fallback: reload after 10 seconds even if server check fails + location.reload(); + }; + waitForServer(); + } else { alert(await r.text()); } From bc7f9c0f79e29280e916d9088912437189f6dcb6 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 1 Feb 2026 05:46:20 +0300 Subject: [PATCH 3/3] feat: implement read-only mode enforcement in API handlers and add corresponding tests --- internal/http/http.go | 8 +++++++ internal/http/http_readonly_test.go | 30 +++++++++++++++++++++++++ internal/webrtc/server.go | 8 +++++++ internal/webrtc/server_readonly_test.go | 30 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 internal/http/http_readonly_test.go create mode 100644 internal/webrtc/server_readonly_test.go diff --git a/internal/http/http.go b/internal/http/http.go index 4b0560c1..76437827 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -111,6 +111,14 @@ func handleTCP(rawURL string) (core.Producer, error) { } func apiStream(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() { + switch r.Method { + case "PUT", "PATCH", "POST", "DELETE": + api.ReadOnlyError(w) + return + } + } + dst := r.URL.Query().Get("dst") stream := streams.Get(dst) if stream == nil { diff --git a/internal/http/http_readonly_test.go b/internal/http/http_readonly_test.go new file mode 100644 index 00000000..3bf992fe --- /dev/null +++ b/internal/http/http_readonly_test.go @@ -0,0 +1,30 @@ +package http + +import ( + stdhttp "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestApiStreamReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/stream?dst=test", nil) + w := httptest.NewRecorder() + + apiStream(w, req) + + require.Equal(t, stdhttp.StatusForbidden, w.Code) + }) + } +} diff --git a/internal/webrtc/server.go b/internal/webrtc/server.go index 48bd5380..584e5f8c 100644 --- a/internal/webrtc/server.go +++ b/internal/webrtc/server.go @@ -21,6 +21,14 @@ const MimeSDP = "application/sdp" var sessions = map[string]*webrtc.Conn{} func syncHandler(w http.ResponseWriter, r *http.Request) { + if api.IsReadOnly() { + switch r.Method { + case "POST", "PATCH", "DELETE": + api.ReadOnlyError(w) + return + } + } + switch r.Method { case "POST": query := r.URL.Query() diff --git a/internal/webrtc/server_readonly_test.go b/internal/webrtc/server_readonly_test.go new file mode 100644 index 00000000..ff975ef3 --- /dev/null +++ b/internal/webrtc/server_readonly_test.go @@ -0,0 +1,30 @@ +package webrtc + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/stretchr/testify/require" +) + +func TestSyncHandlerReadOnly(t *testing.T) { + prevReadOnly := api.ReadOnly + t.Cleanup(func() { + api.ReadOnly = prevReadOnly + }) + + api.ReadOnly = true + + for _, method := range []string{"POST", "PATCH", "DELETE"} { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/webrtc?dst=test", nil) + w := httptest.NewRecorder() + + syncHandler(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + }) + } +}