Merge remote-tracking branch 'origin/260201-readonly' into beta

# Conflicts:
#	README.md
#	www/index.html
This commit is contained in:
Sergey Krashevich
2026-03-10 23:58:57 +03:00
28 changed files with 550 additions and 9 deletions
+2
View File
@@ -462,6 +462,8 @@ api:
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg] allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
# enable auth for localhost (used together with username and password) # enable auth for localhost (used together with username and password)
local_auth: true local_auth: true
# disable write actions in WebUI/API
read_only: true
exec: exec:
# use only allowed exec paths # use only allowed exec paths
+27
View File
@@ -32,6 +32,7 @@ func Init() {
TLSCert string `yaml:"tls_cert"` TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
UnixListen string `yaml:"unix_listen"` UnixListen string `yaml:"unix_listen"`
ReadOnly bool `yaml:"read_only"`
AllowPaths []string `yaml:"allow_paths"` AllowPaths []string `yaml:"allow_paths"`
} `yaml:"api"` } `yaml:"api"`
@@ -50,6 +51,9 @@ func Init() {
allowPaths = cfg.Mod.AllowPaths allowPaths = cfg.Mod.AllowPaths
basePath = cfg.Mod.BasePath basePath = cfg.Mod.BasePath
log = app.GetLogger("api") log = app.GetLogger("api")
ReadOnly = cfg.Mod.ReadOnly
app.ConfigReadOnly = ReadOnly
app.Info["read_only"] = ReadOnly
initStatic(cfg.Mod.StaticDir) initStatic(cfg.Mod.StaticDir)
@@ -149,6 +153,15 @@ const (
) )
var Handler http.Handler 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: // HandleFunc handle pattern with relative path:
// - "api/streams" => "{basepath}/api/streams" // - "api/streams" => "{basepath}/api/streams"
@@ -251,6 +264,11 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if IsReadOnly() {
ReadOnlyError(w)
return
}
s := r.URL.Query().Get("code") s := r.URL.Query().Get("code")
code, err := strconv.Atoi(s) code, err := strconv.Atoi(s)
@@ -269,6 +287,11 @@ func restartHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if IsReadOnly() {
ReadOnlyError(w)
return
}
path, err := os.Executable() path, err := os.Executable()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -287,6 +310,10 @@ func logHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/jsonlines") w.Header().Set("Content-Type", "application/jsonlines")
_, _ = app.MemoryLog.WriteTo(w) _, _ = app.MemoryLog.WriteTo(w)
case "DELETE": case "DELETE":
if IsReadOnly() {
ReadOnlyError(w)
return
}
app.MemoryLog.Reset() app.MemoryLog.Reset()
Response(w, "OK", "text/plain") Response(w, "OK", "text/plain")
default: default:
+4
View File
@@ -30,6 +30,10 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
Response(w, data, "application/yaml") Response(w, data, "application/yaml")
case "POST", "PATCH": case "POST", "PATCH":
if IsReadOnly() {
ReadOnlyError(w)
return
}
data, err := io.ReadAll(r.Body) data, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
+36
View File
@@ -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")
})
}
}
+4
View File
@@ -20,11 +20,15 @@ func LoadConfig(v any) {
} }
var configMu sync.Mutex var configMu sync.Mutex
var ConfigReadOnly bool
func PatchConfig(path []string, value any) error { func PatchConfig(path []string, value any) error {
if ConfigPath == "" { if ConfigPath == "" {
return errors.New("config file disabled") return errors.New("config file disabled")
} }
if ConfigReadOnly {
return errors.New("config is read-only")
}
configMu.Lock() configMu.Lock()
defer configMu.Unlock() defer configMu.Unlock()
+29
View File
@@ -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")
}
+5
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
) )
@@ -12,6 +13,10 @@ func apiFFmpeg(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusMethodNotAllowed) http.Error(w, "", http.StatusMethodNotAllowed)
return return
} }
if api.IsReadOnly() {
api.ReadOnlyError(w)
return
}
query := r.URL.Query() query := r.URL.Query()
dst := query.Get("dst") dst := query.Get("dst")
+26
View File
@@ -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)
}
+4
View File
@@ -43,6 +43,10 @@ func apiDiscovery(w http.ResponseWriter, r *http.Request) {
} }
func apiHomekit(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 { if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
+37
View File
@@ -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)
})
}
+8
View File
@@ -111,6 +111,14 @@ func handleTCP(rawURL string) (core.Producer, error) {
} }
func apiStream(w http.ResponseWriter, r *http.Request) { 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") dst := r.URL.Query().Get("dst")
stream := streams.Get(dst) stream := streams.Get(dst)
if stream == nil { if stream == nil {
+30
View File
@@ -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)
})
}
}
+4
View File
@@ -24,6 +24,10 @@ var Auth struct {
} }
func apiHandle(w http.ResponseWriter, r *http.Request) { func apiHandle(w http.ResponseWriter, r *http.Request) {
if api.IsReadOnly() && r.Method != "GET" {
api.ReadOnlyError(w)
return
}
switch r.Method { switch r.Method {
case "GET": case "GET":
if Auth.UserData == nil { 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)
}
+14
View File
@@ -12,6 +12,13 @@ import (
func apiStreams(w http.ResponseWriter, r *http.Request) { func apiStreams(w http.ResponseWriter, r *http.Request) {
w = creds.SecretResponse(w) w = creds.SecretResponse(w)
if api.IsReadOnly() {
switch r.Method {
case "PUT", "PATCH", "POST", "DELETE":
api.ReadOnlyError(w)
return
}
}
query := r.URL.Query() query := r.URL.Query()
src := query.Get("src") src := query.Get("src")
@@ -130,6 +137,13 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
} }
func apiPreload(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 // GET - return all preloads
if r.Method == "GET" { if r.Method == "GET" {
api.ResponseJSON(w, GetPreloads()) api.ResponseJSON(w, GetPreloads())
+68
View File
@@ -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)
})
}
+8
View File
@@ -21,6 +21,14 @@ const MimeSDP = "application/sdp"
var sessions = map[string]*webrtc.Conn{} var sessions = map[string]*webrtc.Conn{}
func syncHandler(w http.ResponseWriter, r *http.Request) { 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 { switch r.Method {
case "POST": case "POST":
query := r.URL.Query() query := r.URL.Query()
+30
View File
@@ -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)
})
}
}
+4
View File
@@ -59,6 +59,10 @@ func getCloud(email string) (*wyze.Cloud, error) {
} }
func apiWyze(w http.ResponseWriter, r *http.Request) { func apiWyze(w http.ResponseWriter, r *http.Request) {
if api.IsReadOnly() && r.Method != "GET" {
api.ReadOnlyError(w)
return
}
switch r.Method { switch r.Method {
case "GET": case "GET":
apiDeviceList(w, r) apiDeviceList(w, r)
+26
View File
@@ -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)
}
+4
View File
@@ -219,6 +219,10 @@ func wakeUpCamera(url *url.URL) error {
} }
func apiXiaomi(w http.ResponseWriter, r *http.Request) { func apiXiaomi(w http.ResponseWriter, r *http.Request) {
if api.IsReadOnly() && r.Method != "GET" {
api.ReadOnlyError(w)
return
}
switch r.Method { switch r.Method {
case "GET": case "GET":
apiDeviceList(w, r) apiDeviceList(w, r)
+26
View File
@@ -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)
}
+16
View File
@@ -52,8 +52,24 @@
drawTable(table, await r.json()); drawTable(table, await r.json());
} }
</script> </script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (!window.go2rtcReady) return;
window.go2rtcReady.then(data => {
if (!data || !data.read_only) return;
const banner = document.getElementById('readonly-banner');
if (banner) banner.style.display = 'block';
document.querySelectorAll('main input, main select, main button, main textarea').forEach(el => {
el.disabled = true;
});
});
});
</script>
<main> <main>
<div id="readonly-banner" style="display: none; padding: 10px; background: #ffe9e9; border: 1px solid #f0b4b4;">
Read-only mode: add actions are disabled.
</div>
<button id="stream">Temporary stream</button> <button id="stream">Temporary stream</button>
<div> <div>
<form id="stream-form"> <form id="stream-form">
+46 -3
View File
@@ -1182,18 +1182,61 @@
let dump; let dump;
document.getElementById('save').addEventListener('click', async () => { 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';
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'}); let r = await fetch('api/config', {cache: 'no-cache'});
if (r.ok && dump !== await r.text()) { if (r.ok && dump !== await r.text()) {
alert('Config was changed from another place. Refresh the page and make changes again'); alert('Config was changed from another place. Refresh the page and make changes again');
return; 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) { if (r.ok) {
alert('OK'); alert('OK');
dump = editor.getValue(); dump = nextValue;
await fetch('api/restart', {method: 'POST'}); 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 { } else {
alert(await r.text()); alert(await r.text());
} }
+21 -2
View File
@@ -47,11 +47,18 @@
</main> </main>
<script> <script>
const templates = [ let templates = [
'<a href="stream.html?src={name}">stream</a>', '<a href="stream.html?src={name}">stream</a>',
'<a href="links.html?src={name}">links</a>', '<a href="links.html?src={name}">links</a>',
'<a href="#" data-name="{name}">delete</a>', '<a href="#" data-name="{name}">delete</a>',
]; ];
let readOnly = false;
const applyReadOnly = (flag) => {
if (!flag || readOnly) return;
readOnly = true;
templates = templates.filter(link => link.indexOf('delete') < 0);
};
document.querySelector('.controls > button') document.querySelector('.controls > button')
.addEventListener('click', () => { .addEventListener('click', () => {
@@ -263,6 +270,8 @@
function updateInfo() { function updateInfo() {
return fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => { return fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => {
applyReadOnly(Boolean(data.read_only));
const cpuUsage = clampPercent(data.system?.cpu_usage); const cpuUsage = clampPercent(data.system?.cpu_usage);
const memUsed = toNumber(data.system?.mem_used); const memUsed = toNumber(data.system?.mem_used);
const memTotal = toNumber(data.system?.mem_total); const memTotal = toNumber(data.system?.mem_total);
@@ -287,11 +296,21 @@
}); });
} }
const applyInfo = (data) => {
applyReadOnly(Boolean(data.read_only));
updateInfo().then(isSupported => { updateInfo().then(isSupported => {
if (isSupported) startInfoUpdates(); if (isSupported) startInfoUpdates();
}); });
};
reload(); if (window.go2rtcReady) {
window.go2rtcReady.then(data => {
if (data) applyInfo(data);
});
} else {
const url = new URL('api', location.href);
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => applyInfo(data));
}
</script> </script>
</body> </body>
+15
View File
@@ -154,6 +154,21 @@ Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
fetch(url, {method: 'POST'}); fetch(url, {method: 'POST'});
}); });
</script> </script>
<script>
const applyReadOnly = () => {
const ids = ['play-send', 'play-url', 'pub-send', 'pub-url'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = true;
});
};
if (window.go2rtcReady) {
window.go2rtcReady.then(data => {
if (data && data.read_only) applyReadOnly();
});
}
</script>
<div id="webrtc"> <div id="webrtc">
<h2>WebRTC Magic</h2> <h2>WebRTC Magic</h2>
+21
View File
@@ -136,3 +136,24 @@ document.body.innerHTML = `
</nav> </nav>
</header> </header>
` + 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';
});
});
+5
View File
@@ -68,6 +68,11 @@
"password": { "password": {
"type": "string" "type": "string"
}, },
"read_only": {
"description": "Disable write actions in WebUI/API",
"type": "boolean",
"default": false
},
"local_auth": { "local_auth": {
"description": "Enable auth check for localhost requests", "description": "Enable auth check for localhost requests",
"type": "boolean", "type": "boolean",