feat: add read-only mode to API and UI, disable write actions
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user