From cc453dd9ed84c354917ccd7057b14eb35b2c694d Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 1 Feb 2026 04:13:43 +0300 Subject: [PATCH 01/57] 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 02/57] 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 03/57] 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) + }) + } +} From fe5736905eccea39b07824d0eed96ec20c5d10bc Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 3 Feb 2026 09:51:09 +0300 Subject: [PATCH 04/57] feat: redesign web UI with theme toggle and improved styling --- www/config.html | 8 +- www/index.html | 769 +++++++++++++++++++++++++++++++++++++++++++++--- www/main.js | 456 ++++++++++++++++++++++++---- 3 files changed, 1129 insertions(+), 104 deletions(-) diff --git a/www/config.html b/www/config.html index 026b5beb..83c44c7b 100644 --- a/www/config.html +++ b/www/config.html @@ -116,8 +116,12 @@ ensureYamlLanguage(); - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - monaco.editor.setTheme(prefersDark ? 'vs-dark' : 'vs'); + const getTheme = () => document.documentElement.getAttribute('data-theme') === 'light' ? 'vs' : 'vs-dark'; + monaco.editor.setTheme(getTheme()); + + window.addEventListener('themeChanged', () => { + monaco.editor.setTheme(getTheme()); + }); const editor = monaco.editor.create(container, { language: 'yaml', diff --git a/www/index.html b/www/index.html index 69126e6f..ab70563a 100644 --- a/www/index.html +++ b/www/index.html @@ -4,65 +4,729 @@ go2rtc + + + - +
+
+ +
+
-
- - modes - - - - +
+
+ +
+ Modes: + + + + +
+
+ +
+ + + + + + + + + + +
+ + StatusActions
+
+ +
- - - - - - - - - - -
onlinecommands
-
@@ -123,7 +126,9 @@ } document.getElementById('homekit').addEventListener('click', async ev => { - ev.target.nextElementSibling.style.display = 'grid'; + const div = ev.target.nextElementSibling; + if (div.style.display === 'grid') { div.style.display = 'none'; return; } + div.style.display = 'grid'; await reloadHomeKit(); }); @@ -168,7 +173,9 @@
@@ -180,7 +187,9 @@ @@ -192,7 +201,9 @@ @@ -211,7 +222,8 @@ @@ -243,7 +257,9 @@ @@ -259,7 +275,9 @@ @@ -433,7 +457,11 @@ From 92eaaddceff6c90d2b48a53ee077002981db9c8f Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 3 Feb 2026 12:38:43 +0300 Subject: [PATCH 08/57] style(config): update styles for main and config sections in HTML --- www/config.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/www/config.html b/www/config.html index 83c44c7b..090ae21b 100644 --- a/www/config.html +++ b/www/config.html @@ -9,10 +9,16 @@ height: 100%; } + main { + flex: 0 0 auto; + padding: 10px 0; + } + #config { flex: 1 1 auto; - border-top: 1px solid #ccc; + border-top: 1px solid var(--border-color); min-height: 300px; + overflow: hidden; } @@ -1177,8 +1183,6 @@ }; const layout = () => { - const top = container.getBoundingClientRect().top; - container.style.height = `${Math.max(200, window.innerHeight - top)}px`; editor.layout(); }; window.addEventListener('resize', layout); From 3b33ffe2e263b9c897425c72b935e6500331762f Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 3 Feb 2026 13:18:58 +0300 Subject: [PATCH 09/57] webui: link to docs --- www/index.html | 15 ++++++++++++++- www/main.js | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/www/index.html b/www/index.html index 0cb3aea7..7dd1cb0c 100644 --- a/www/index.html +++ b/www/index.html @@ -189,6 +189,17 @@ box-shadow: var(--glow-cyan); } + .docs-link { + font-size: 11px; + padding: 4px 10px; + opacity: 0.6; + margin-left: auto; + } + + .docs-link:hover { + opacity: 1; + } + /* Main content */ main { padding: 40px 0; @@ -600,13 +611,15 @@
/ - info + info / - probe + probe / network ` + diff --git a/www/info.html b/www/info.html new file mode 100644 index 00000000..47d0708d --- /dev/null +++ b/www/info.html @@ -0,0 +1,615 @@ + + + + + + Stream Info - go2rtc + + + + + + + +
+
+ +
+
+ +
+
+ ← Back to Streams + + + +
+
+
+
Loading stream information...
+
+
+
+
+ + + + + diff --git a/www/probe.html b/www/probe.html new file mode 100644 index 00000000..7158ed03 --- /dev/null +++ b/www/probe.html @@ -0,0 +1,618 @@ + + + + + + Stream Probe - go2rtc + + + + + + + +
+
+ +
+
+ +
+
+ ← Back to Streams + + + +
+
+
+
Probing stream (video=all, audio=all, microphone)...
+
+
+
+
+ + + + + From e37da9a05650fd18f12b4a206acfcfde839f013b Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Wed, 4 Feb 2026 05:28:12 +0300 Subject: [PATCH 16/57] style: update theme toggle styles to remove borders and animations --- www/index.html | 5 +---- www/info.html | 5 +---- www/main.js | 5 +---- www/probe.html | 5 +---- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/www/index.html b/www/index.html index 4be19893..7e1524a2 100644 --- a/www/index.html +++ b/www/index.html @@ -622,7 +622,7 @@ .theme-toggle { width: 48px; height: 48px; - border: 1px solid var(--border-color); + border: none; border-radius: 6px; background: var(--bg-card); color: var(--accent-cyan); @@ -631,15 +631,12 @@ align-items: center; justify-content: center; font-size: 20px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); margin-left: auto; } .theme-toggle:hover { background: var(--accent-cyan); color: var(--bg-primary); - box-shadow: var(--glow-cyan); - transform: rotate(180deg); } diff --git a/www/info.html b/www/info.html index 47d0708d..fcbee7c4 100644 --- a/www/info.html +++ b/www/info.html @@ -201,7 +201,7 @@ .theme-toggle { width: 48px; height: 48px; - border: 1px solid var(--border-color); + border: none; border-radius: 6px; background: var(--bg-card); color: var(--accent-cyan); @@ -210,15 +210,12 @@ align-items: center; justify-content: center; font-size: 20px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); margin-left: auto; } .theme-toggle:hover { background: var(--accent-cyan); color: var(--bg-primary); - box-shadow: var(--glow-cyan); - transform: rotate(180deg); } main { diff --git a/www/main.js b/www/main.js index 633de9ea..7c78f87d 100644 --- a/www/main.js +++ b/www/main.js @@ -418,7 +418,7 @@ if (!document.querySelector('.logo')) { .theme-toggle { width: 48px; height: 48px; - border: 1px solid var(--border-color); + border: none; border-radius: 6px; background: var(--bg-card); color: var(--accent-cyan); @@ -427,15 +427,12 @@ if (!document.querySelector('.logo')) { align-items: center; justify-content: center; font-size: 20px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); margin-left: auto; } .theme-toggle:hover { background: var(--accent-cyan); color: var(--bg-primary); - box-shadow: var(--glow-cyan); - transform: rotate(180deg); } `; diff --git a/www/probe.html b/www/probe.html index 7158ed03..12a7dd34 100644 --- a/www/probe.html +++ b/www/probe.html @@ -201,7 +201,7 @@ .theme-toggle { width: 48px; height: 48px; - border: 1px solid var(--border-color); + border: none; border-radius: 6px; background: var(--bg-card); color: var(--accent-cyan); @@ -210,15 +210,12 @@ align-items: center; justify-content: center; font-size: 20px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); margin-left: auto; } .theme-toggle:hover { background: var(--accent-cyan); color: var(--bg-primary); - box-shadow: var(--glow-cyan); - transform: rotate(180deg); } main { From 1b06558140c937926ab9acb3e6b8ef7f3b4e929d Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Wed, 4 Feb 2026 06:04:05 +0300 Subject: [PATCH 17/57] feat(styles): add external stylesheet for consistent theming and layout --- www/index.html | 314 +--------------------------------- www/info.html | 258 +--------------------------- www/links.html | 67 ++------ www/main.js | 437 +---------------------------------------------- www/probe.html | 258 +--------------------------- www/static.go | 1 + www/styles.css | 447 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 473 insertions(+), 1309 deletions(-) create mode 100644 www/styles.css diff --git a/www/index.html b/www/index.html index 7e1524a2..b57938e4 100644 --- a/www/index.html +++ b/www/index.html @@ -7,205 +7,9 @@ + diff --git a/www/info.html b/www/info.html index fcbee7c4..3e26180a 100644 --- a/www/info.html +++ b/www/info.html @@ -7,220 +7,9 @@ + -`; - document.body.innerHTML = `
diff --git a/www/probe.html b/www/probe.html index 12a7dd34..0de60172 100644 --- a/www/probe.html +++ b/www/probe.html @@ -7,220 +7,9 @@ + @@ -144,16 +146,105 @@ // Auto-reload setInterval(reload, 1000); - const url = new URL('api', location.href); - fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => { - const info = document.querySelector('.info'); - const parts = [ - `version: ${data.version}`, - `pid: ${data.pid}`, - `config: ${data.config_path}`, + const info = document.querySelector('.info'); + const infoURL = new URL('api', location.href); + const cpuHistory = []; + const memHistory = []; + const graphWidth = 36; + const graphHeight = 8; + const infoUpdateInterval = window.SYSTEM_INFO_UPDATE_INTERVAL_MS ?? 2000; + + function toNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + + function clampPercent(value) { + return Math.max(0, Math.min(100, toNumber(value))); + } + + function pushHistory(history, value) { + history.push(value); + while (history.length > graphWidth) history.shift(); + } + + function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = Math.max(0, toNumber(bytes)); + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index++; + } + return `${value.toFixed(value >= 100 || index === 0 ? 0 : 1)} ${units[index]}`; + } + + function renderAsciiGraphLines(history) { + const bars = Array.from({length: graphWidth}, (_, index) => { + const value = history[index] ?? 0; + return Math.round((value / 100) * graphHeight); + }); + const lines = []; + for (let row = graphHeight; row >= 1; row--) { + let line = '|'; + for (const bar of bars) { + line += bar >= row ? '#' : ' '; + } + line += '|'; + lines.push(line); + } + lines.push('+' + '-'.repeat(graphWidth) + '+'); + return lines; + } + + function padRight(text, width) { + return text + ' '.repeat(Math.max(0, width - text.length)); + } + + function renderInfo(data, cpuUsage, memUsage, memUsed, memTotal) { + const cpuLines = renderAsciiGraphLines(cpuHistory); + const memLines = renderAsciiGraphLines(memHistory); + const graphLines = []; + const graphBlockWidth = graphWidth + 2; // borders + const cpuTitle = `CPU ${cpuUsage.toFixed(1)}%`; + const memTitle = `MEM ${memUsage.toFixed(1)}% (${formatBytes(memUsed)} / ${formatBytes(memTotal)})`; + + graphLines.push( + `${padRight(cpuTitle, graphBlockWidth)} ${padRight(memTitle, graphBlockWidth)}` + ); + for (let i = 0; i < cpuLines.length; i++) { + graphLines.push(`${cpuLines[i]} ${memLines[i]}`); + } + + const lines = [ + `version: ${data.version ?? '-'}`, + `pid: ${data.pid ?? '-'}`, + `config: ${data.config_path ?? '-'}`, + '', + ...graphLines, ]; - info.innerText = parts.join(' / '); - }); + info.textContent = lines.join('\n'); + } + + function updateInfo() { + fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => { + const cpuUsage = clampPercent(data.system?.cpu_usage); + const memUsed = toNumber(data.system?.mem_used); + const memTotal = toNumber(data.system?.mem_total); + const memUsage = memTotal > 0 ? clampPercent((memUsed * 100) / memTotal) : 0; + + pushHistory(cpuHistory, cpuUsage); + pushHistory(memHistory, memUsage); + renderInfo(data, cpuUsage, memUsage, memUsed, memTotal); + }).catch(error => { + if (!info.textContent) { + info.textContent = `Can't load system info: ${error.message}`; + } + }); + } + + updateInfo(); + setInterval(updateInfo, infoUpdateInterval); reload(); diff --git a/www/main.js b/www/main.js index d5629178..862a3673 100644 --- a/www/main.js +++ b/www/main.js @@ -122,6 +122,9 @@ document.head.innerHTML += ` `; +// Common UI refresh intervals (ms) +window.SYSTEM_INFO_UPDATE_INTERVAL_MS = 2000; + document.body.innerHTML = `