mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): harden launcher access control
This commit is contained in:
@@ -81,6 +81,8 @@ That file currently stores:
|
||||
- `port`
|
||||
- `public`
|
||||
- `allowed_cidrs`
|
||||
- `allow_localhost_bypass`
|
||||
- `trusted_proxy_cidrs`
|
||||
|
||||
If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
|
||||
If they are omitted, stored launcher settings are used.
|
||||
@@ -152,6 +154,8 @@ When public access is enabled:
|
||||
|
||||
- the launcher still protects the dashboard with password login
|
||||
- optional `allowed_cidrs` can restrict which client IP ranges may connect
|
||||
- `allow_localhost_bypass` defaults to `true`; set it to `false` when same-host proxies or tunnels should not bypass `allowed_cidrs`
|
||||
- optional `trusted_proxy_cidrs` can trust specific reverse proxies to supply the original client IP through headers such as `X-Forwarded-For`
|
||||
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
|
||||
|
||||
## Build And Run
|
||||
|
||||
@@ -9,9 +9,19 @@ import (
|
||||
)
|
||||
|
||||
type launcherConfigPayload struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
AllowLocalhostBypass bool `json:"allow_localhost_bypass"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
}
|
||||
|
||||
type launcherConfigUpdatePayload struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
AllowLocalhostBypass *bool `json:"allow_localhost_bypass"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||
@@ -29,9 +39,11 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
|
||||
port = launcherconfig.DefaultPort
|
||||
}
|
||||
return launcherconfig.Config{
|
||||
Port: port,
|
||||
Public: h.serverPublic,
|
||||
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
||||
Port: port,
|
||||
Public: h.serverPublic,
|
||||
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
||||
AllowLocalhostBypass: h.serverAllowLocalhostBypass,
|
||||
TrustedProxyCIDRs: append([]string(nil), h.serverTrustedProxyCIDRs...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +60,16 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
AllowLocalhostBypass: cfg.AllowLocalhostBypass,
|
||||
TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var payload launcherConfigPayload
|
||||
var payload launcherConfigUpdatePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
@@ -69,6 +83,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||
cfg.Port = payload.Port
|
||||
cfg.Public = payload.Public
|
||||
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
|
||||
if payload.AllowLocalhostBypass != nil {
|
||||
cfg.AllowLocalhostBypass = *payload.AllowLocalhostBypass
|
||||
}
|
||||
cfg.TrustedProxyCIDRs = append([]string(nil), payload.TrustedProxyCIDRs...)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
if err := launcherconfig.Validate(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -82,8 +100,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
AllowLocalhostBypass: cfg.AllowLocalhostBypass,
|
||||
TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"})
|
||||
h.SetServerAccessOptions(false, []string{"10.0.0.0/8"})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
@@ -38,6 +39,12 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
|
||||
}
|
||||
if got.AllowLocalhostBypass {
|
||||
t.Fatalf("response allow_localhost_bypass = true, want false")
|
||||
}
|
||||
if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
|
||||
t.Fatalf("response trusted_proxy_cidrs = %v, want [10.0.0.0/8]", got.TrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
@@ -60,7 +67,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"allow_localhost_bypass":false,"trusted_proxy_cidrs":["10.0.0.0/8"]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -86,6 +93,83 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
|
||||
}
|
||||
if cfg.AllowLocalhostBypass {
|
||||
t.Fatalf("saved config allow_localhost_bypass = true, want false")
|
||||
}
|
||||
if len(cfg.TrustedProxyCIDRs) != 1 || cfg.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
|
||||
t.Fatalf("saved config trusted_proxy_cidrs = %v, want [10.0.0.0/8]", cfg.TrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigUsesDirectAccessFields(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(
|
||||
`{"port":18080,"public":false,"allowed_cidrs":["192.168.1.0/24"],"allow_localhost_bypass":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := launcherconfig.Load(launcherconfig.PathForAppConfig(configPath), launcherconfig.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("launcherconfig.Load() error = %v", err)
|
||||
}
|
||||
if !cfg.AllowLocalhostBypass {
|
||||
t.Fatal("saved config allow_localhost_bypass = false, want true")
|
||||
}
|
||||
if len(cfg.TrustedProxyCIDRs) != 1 || cfg.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
|
||||
t.Fatalf("saved config trusted_proxy_cidrs = %v, want [10.0.0.0/8]", cfg.TrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigKeepsLocalhostBypassWhenOmitted(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
path := launcherconfig.PathForAppConfig(configPath)
|
||||
if err := os.WriteFile(
|
||||
path,
|
||||
[]byte(`{"port":18800,"public":false,"allow_localhost_bypass":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("launcherconfig.Load() error = %v", err)
|
||||
}
|
||||
if !cfg.AllowLocalhostBypass {
|
||||
t.Fatal("saved config allow_localhost_bypass = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {
|
||||
@@ -129,3 +213,24 @@ func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLauncherConfigRejectsInvalidTrustedProxyCIDR(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(`{"port":18080,"public":false,"trusted_proxy_cidrs":["bad-cidr"]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
+29
-21
@@ -10,32 +10,35 @@ import (
|
||||
|
||||
// Handler serves HTTP API requests.
|
||||
type Handler struct {
|
||||
configPath string
|
||||
serverPort int
|
||||
serverPublic bool
|
||||
serverPublicExplicit bool
|
||||
serverHostInput string
|
||||
serverHostExplicit bool
|
||||
serverCIDRs []string
|
||||
debug bool
|
||||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
weixinMu sync.Mutex
|
||||
weixinFlows map[string]*weixinFlow
|
||||
wecomMu sync.Mutex
|
||||
wecomFlows map[string]*wecomFlow
|
||||
configPath string
|
||||
serverPort int
|
||||
serverPublic bool
|
||||
serverPublicExplicit bool
|
||||
serverHostInput string
|
||||
serverHostExplicit bool
|
||||
serverCIDRs []string
|
||||
serverAllowLocalhostBypass bool
|
||||
serverTrustedProxyCIDRs []string
|
||||
debug bool
|
||||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
weixinMu sync.Mutex
|
||||
weixinFlows map[string]*weixinFlow
|
||||
wecomMu sync.Mutex
|
||||
wecomFlows map[string]*wecomFlow
|
||||
}
|
||||
|
||||
// NewHandler creates an instance of the API handler.
|
||||
func NewHandler(configPath string) *Handler {
|
||||
return &Handler{
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
weixinFlows: make(map[string]*weixinFlow),
|
||||
wecomFlows: make(map[string]*wecomFlow),
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
serverAllowLocalhostBypass: launcherconfig.Default().AllowLocalhostBypass,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
oauthState: make(map[string]string),
|
||||
weixinFlows: make(map[string]*weixinFlow),
|
||||
wecomFlows: make(map[string]*wecomFlow),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +52,11 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
|
||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||
}
|
||||
|
||||
func (h *Handler) SetServerAccessOptions(allowLocalhostBypass bool, trustedProxyCIDRs []string) {
|
||||
h.serverAllowLocalhostBypass = allowLocalhostBypass
|
||||
h.serverTrustedProxyCIDRs = append([]string(nil), trustedProxyCIDRs...)
|
||||
}
|
||||
|
||||
// SetServerBindHost stores the launcher's effective bind host.
|
||||
// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
|
||||
func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
|
||||
|
||||
@@ -23,6 +23,8 @@ type Config struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||
AllowLocalhostBypass bool `json:"allow_localhost_bypass"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
|
||||
// LegacyLauncherToken is read only for one-time migration from the removed
|
||||
// token login flow. Save always clears it so new configs do not persist it.
|
||||
@@ -31,7 +33,7 @@ type Config struct {
|
||||
|
||||
// Default returns default launcher settings.
|
||||
func Default() Config {
|
||||
return Config{Port: DefaultPort, Public: false}
|
||||
return Config{Port: DefaultPort, Public: false, AllowLocalhostBypass: true}
|
||||
}
|
||||
|
||||
// Validate checks if launcher settings are valid.
|
||||
@@ -44,6 +46,11 @@ func Validate(cfg Config) error {
|
||||
return fmt.Errorf("invalid CIDR %q", cidr)
|
||||
}
|
||||
}
|
||||
for _, cidr := range cfg.TrustedProxyCIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return fmt.Errorf("invalid trusted proxy CIDR %q", cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,6 +102,7 @@ func Load(path string, fallback Config) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
|
||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
|
||||
if err := Validate(cfg); err != nil {
|
||||
@@ -106,6 +114,7 @@ func Load(path string, fallback Config) (Config, error) {
|
||||
// Save writes launcher settings to disk.
|
||||
func Save(path string, cfg Config) error {
|
||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
|
||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
if err := Validate(cfg); err != nil {
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||
fallback := Config{Port: 19999, Public: true}
|
||||
fallback := Default()
|
||||
fallback.Port = 19999
|
||||
fallback.Public = true
|
||||
|
||||
got, err := Load(path, fallback)
|
||||
if err != nil {
|
||||
@@ -18,6 +20,9 @@ func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||
if got.Port != fallback.Port || got.Public != fallback.Public {
|
||||
t.Fatalf("Load() = %+v, want %+v", got, fallback)
|
||||
}
|
||||
if !got.AllowLocalhostBypass {
|
||||
t.Fatal("allow_localhost_bypass = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
@@ -27,6 +32,8 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
Port: 18080,
|
||||
Public: true,
|
||||
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
|
||||
AllowLocalhostBypass: false,
|
||||
TrustedProxyCIDRs: []string{"172.16.0.0/12"},
|
||||
DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
|
||||
LegacyLauncherToken: "legacy-token-should-not-persist",
|
||||
}
|
||||
@@ -41,6 +48,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
if got.Port != want.Port || got.Public != want.Public {
|
||||
t.Fatalf("Load() = %+v, want %+v", got, want)
|
||||
}
|
||||
if got.AllowLocalhostBypass != want.AllowLocalhostBypass {
|
||||
t.Fatalf("allow_localhost_bypass = %t, want %t", got.AllowLocalhostBypass, want.AllowLocalhostBypass)
|
||||
}
|
||||
if got.DashboardPasswordHash != want.DashboardPasswordHash {
|
||||
t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash)
|
||||
}
|
||||
@@ -55,6 +65,14 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])
|
||||
}
|
||||
}
|
||||
if len(got.TrustedProxyCIDRs) != len(want.TrustedProxyCIDRs) {
|
||||
t.Fatalf("trusted_proxy_cidrs len = %d, want %d", len(got.TrustedProxyCIDRs), len(want.TrustedProxyCIDRs))
|
||||
}
|
||||
for i := range want.TrustedProxyCIDRs {
|
||||
if got.TrustedProxyCIDRs[i] != want.TrustedProxyCIDRs[i] {
|
||||
t.Fatalf("trusted_proxy_cidrs[%d] = %q, want %q", i, got.TrustedProxyCIDRs[i], want.TrustedProxyCIDRs[i])
|
||||
}
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@@ -80,6 +98,21 @@ func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAllowLocalhostBypassForLegacyConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"port":18800,"allowed_cidrs":["10.0.0.0/8"]}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := Load(path, Default())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if !got.AllowLocalhostBypass {
|
||||
t.Fatal("allow_localhost_bypass = false, want true for legacy config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
||||
t.Fatal("Validate() expected error for port 0")
|
||||
@@ -99,6 +132,16 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidTrustedProxyCIDR(t *testing.T) {
|
||||
err := Validate(Config{
|
||||
Port: 18800,
|
||||
TrustedProxyCIDRs: []string{"not-a-cidr"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Validate() expected error for invalid trusted proxy CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCIDRs(t *testing.T) {
|
||||
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
||||
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||
|
||||
+9
-1
@@ -585,13 +585,21 @@ func main() {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||
}
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||
apiHandler.SetServerAccessOptions(
|
||||
launcherCfg.AllowLocalhostBypass,
|
||||
launcherCfg.TrustedProxyCIDRs,
|
||||
)
|
||||
apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
// Frontend Embedded Assets
|
||||
registerEmbedRoutes(mux)
|
||||
|
||||
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
||||
accessControlledMux, err := middleware.IPAllowlist(middleware.IPAllowlistConfig{
|
||||
AllowedCIDRs: launcherCfg.AllowedCIDRs,
|
||||
AllowLocalhostBypass: launcherCfg.AllowLocalhostBypass,
|
||||
TrustedProxyCIDRs: launcherCfg.TrustedProxyCIDRs,
|
||||
}, mux)
|
||||
if err != nil {
|
||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||
}
|
||||
|
||||
@@ -7,42 +7,77 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPAllowlistConfig controls launcher network access decisions.
|
||||
type IPAllowlistConfig struct {
|
||||
AllowedCIDRs []string
|
||||
AllowLocalhostBypass bool
|
||||
TrustedProxyCIDRs []string
|
||||
}
|
||||
|
||||
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
||||
// Loopback addresses are always allowed for local administration.
|
||||
// Loopback addresses can optionally bypass CIDR checks for local administration.
|
||||
// X-Forwarded-For is only trusted when the immediate peer is in a trusted CIDR.
|
||||
// Empty CIDR list means no restriction.
|
||||
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
||||
if len(allowedCIDRs) == 0 {
|
||||
func IPAllowlist(cfg IPAllowlistConfig, next http.Handler) (http.Handler, error) {
|
||||
allowedNets, err := parseCIDRNets(cfg.AllowedCIDRs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trustedProxyNets, err := parseCIDRNets(cfg.TrustedProxyCIDRs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(allowedNets) == 0 {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
||||
for _, cidr := range allowedCIDRs {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
peerIP := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||
if peerIP == nil {
|
||||
rejectByPolicy(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ip := peerIP
|
||||
if containsIP(trustedProxyNets, peerIP) {
|
||||
if forwardedIP := clientIPFromXForwardedFor(r.Header.Get("X-Forwarded-For")); forwardedIP != nil {
|
||||
ip = forwardedIP
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.AllowLocalhostBypass && ip.IsLoopback() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if containsIP(allowedNets, ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rejectByPolicy(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func parseCIDRNets(cidrs []string) ([]*net.IPNet, error) {
|
||||
nets := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets, nil
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||
if ip == nil {
|
||||
rejectByPolicy(w, r)
|
||||
return
|
||||
func containsIP(nets []*net.IPNet, ip net.IP) bool {
|
||||
for _, ipNet := range nets {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
for _, ipNet := range nets {
|
||||
if ipNet.Contains(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rejectByPolicy(w, r)
|
||||
}), nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
@@ -53,6 +88,21 @@ func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func clientIPFromXForwardedFor(header string) net.IP {
|
||||
first, _, _ := strings.Cut(header, ",")
|
||||
first = strings.Trim(strings.TrimSpace(first), `"`)
|
||||
if first == "" {
|
||||
return nil
|
||||
}
|
||||
if ip := net.ParseIP(first); ip != nil {
|
||||
return ip
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(first); err == nil {
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
||||
h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -25,7 +25,9 @@ func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -43,7 +45,9 @@ func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -60,8 +64,11 @@ func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
func TestIPAllowlist_AllowsLoopbackWhenBypassEnabled(t *testing.T) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
AllowLocalhostBypass: true,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -78,9 +85,85 @@ func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_RejectsLoopbackWhenBypassDisabled(t *testing.T) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_IgnoresXForwardedForFromUntrustedPeer(t *testing.T) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.5:1234"
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.88")
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_UsesXForwardedForFromTrustedPeer(t *testing.T) {
|
||||
h, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
TrustedProxyCIDRs: []string{"10.0.0.0/8"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.8:1234"
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.88, 203.0.113.5")
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
|
||||
_, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
_, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"bad-cidr"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
if err == nil {
|
||||
t.Fatal("IPAllowlist() expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_InvalidTrustedProxyCIDR(t *testing.T) {
|
||||
_, err := IPAllowlist(IPAllowlistConfig{
|
||||
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||
TrustedProxyCIDRs: []string{"bad-cidr"},
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
if err == nil {
|
||||
t.Fatal("IPAllowlist() expected error for invalid trusted proxy CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface LauncherConfig {
|
||||
port: number
|
||||
public: boolean
|
||||
allowed_cidrs: string[]
|
||||
allow_localhost_bypass: boolean
|
||||
trusted_proxy_cidrs: string[]
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
|
||||
@@ -165,6 +165,10 @@ export function ConfigPage() {
|
||||
port: String(launcherConfig.port),
|
||||
publicAccess: launcherConfig.public,
|
||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||
allowLocalhostBypass: launcherConfig.allow_localhost_bypass ?? true,
|
||||
trustedProxyCIDRsText: (launcherConfig.trusted_proxy_cidrs ?? []).join(
|
||||
"\n",
|
||||
),
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
@@ -182,7 +186,11 @@ export function ConfigPage() {
|
||||
const launcherSettingsDirty =
|
||||
launcherForm.port !== launcherBaseline.port ||
|
||||
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
|
||||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText
|
||||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText ||
|
||||
launcherForm.allowLocalhostBypass !==
|
||||
launcherBaseline.allowLocalhostBypass ||
|
||||
launcherForm.trustedProxyCIDRsText !==
|
||||
launcherBaseline.trustedProxyCIDRsText
|
||||
const launcherPasswordDirty =
|
||||
launcherForm.dashboardPassword.trim() !== "" ||
|
||||
launcherForm.dashboardPasswordConfirm.trim() !== ""
|
||||
@@ -637,10 +645,15 @@ export function ConfigPage() {
|
||||
max: 65535,
|
||||
})
|
||||
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
||||
const trustedProxyCIDRs = parseCIDRText(
|
||||
launcherForm.trustedProxyCIDRsText,
|
||||
)
|
||||
const savedLauncherConfig = await updateLauncherConfig({
|
||||
port,
|
||||
public: launcherForm.publicAccess,
|
||||
allowed_cidrs: allowedCIDRs,
|
||||
allow_localhost_bypass: launcherForm.allowLocalhostBypass,
|
||||
trusted_proxy_cidrs: trustedProxyCIDRs,
|
||||
})
|
||||
const parsedLauncher: LauncherForm = {
|
||||
port: String(savedLauncherConfig.port),
|
||||
@@ -648,6 +661,11 @@ export function ConfigPage() {
|
||||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||
"\n",
|
||||
),
|
||||
allowLocalhostBypass:
|
||||
savedLauncherConfig.allow_localhost_bypass ?? true,
|
||||
trustedProxyCIDRsText: (
|
||||
savedLauncherConfig.trusted_proxy_cidrs ?? []
|
||||
).join("\n"),
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
|
||||
@@ -1243,6 +1243,34 @@ export function LauncherSection({
|
||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.allow_localhost_bypass")}
|
||||
hint={t("pages.config.allow_localhost_bypass_hint")}
|
||||
layout="setting-row"
|
||||
checked={launcherForm.allowLocalhostBypass}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("allowLocalhostBypass", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.trusted_proxy_cidrs")}
|
||||
hint={t("pages.config.trusted_proxy_cidrs_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.trustedProxyCIDRsText}
|
||||
disabled={disabled}
|
||||
placeholder={t("pages.config.trusted_proxy_cidrs_placeholder")}
|
||||
className="min-h-[88px]"
|
||||
onChange={(e) =>
|
||||
onFieldChange("trustedProxyCIDRsText", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface LauncherForm {
|
||||
port: string
|
||||
publicAccess: boolean
|
||||
allowedCIDRsText: string
|
||||
allowLocalhostBypass: boolean
|
||||
trustedProxyCIDRsText: string
|
||||
dashboardPassword: string
|
||||
dashboardPasswordConfirm: string
|
||||
}
|
||||
@@ -163,6 +165,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||
port: "18800",
|
||||
publicAccess: false,
|
||||
allowedCIDRsText: "",
|
||||
allowLocalhostBypass: true,
|
||||
trustedProxyCIDRsText: "",
|
||||
dashboardPassword: "",
|
||||
dashboardPasswordConfirm: "",
|
||||
}
|
||||
|
||||
@@ -849,6 +849,11 @@
|
||||
"allowed_cidrs": "অনুমোদিত নেটওয়ার্ক CIDR",
|
||||
"allowed_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের ক্লায়েন্টরা সার্ভিস অ্যাক্সেস করতে পারে। প্রতি লাইনে একটি বা কমা দিয়ে আলাদা। সবাইকে অনুমতি দিতে খালি রাখুন।",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"allow_localhost_bypass": "Localhost bypass অনুমতি দিন",
|
||||
"allow_localhost_bypass_hint": "সক্ষম থাকলে, localhost অনুরোধ অনুমোদিত CIDR না মিললেও অনুমতি পাবে। একই হোস্টের proxy এর পেছনে launcher থাকলে এটি বন্ধ করুন।",
|
||||
"trusted_proxy_cidrs": "বিশ্বস্ত proxy CIDR",
|
||||
"trusted_proxy_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের proxy X-Forwarded-For-এর মতো client IP তথ্য পাঠাতে পারে। proxy client IP header উপেক্ষা করতে খালি রাখুন।",
|
||||
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
|
||||
"evolution_section_hint": "এজেন্টকে সম্পন্ন টার্ন থেকে শিখতে দিন এবং দক্ষতার উন্নতি প্রস্তুত করুন।",
|
||||
"evolution_enabled": "ইভোলিউশন সক্ষম করুন",
|
||||
"evolution_enabled_hint": "সম্পন্ন টার্নের জন্য শেখার ডেটা রেকর্ড করুন। ড্রাফ্ট এবং অ্যাপ্লাই মোড দক্ষতা আপডেটও তৈরি করতে পারে।",
|
||||
|
||||
@@ -848,6 +848,11 @@
|
||||
"allowed_cidrs": "Povolené síťové CIDRy",
|
||||
"allowed_cidrs_hint": "Ke službě mají přístup pouze klienti z těchto CIDR rozsahů. Jeden na řádek nebo oddělené čárkou. Ponechte prázdné pro povolení všech.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"allow_localhost_bypass": "Povolit obcházení přes localhost",
|
||||
"allow_localhost_bypass_hint": "Když je zapnuto, požadavky z localhostu jsou povoleny i mimo povolené CIDRy. Vypněte, pokud je launcher za proxy na stejném hostu.",
|
||||
"trusted_proxy_cidrs": "Důvěryhodné CIDRy proxy",
|
||||
"trusted_proxy_cidrs_hint": "Pouze proxy z těchto CIDR rozsahů mohou předávat informace o IP klienta, například X-Forwarded-For. Ponechte prázdné pro ignorování proxy hlaviček s IP klienta.",
|
||||
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
|
||||
"evolution_section_hint": "Nechte agenta učit se z dokončených tahů a připravovat vylepšení dovedností.",
|
||||
"evolution_enabled": "Povolit evoluci",
|
||||
"evolution_enabled_hint": "Zaznamenávat data učení pro dokončené tahy. Režimy Návrh a Aplikovat mohou také generovat aktualizace dovedností.",
|
||||
|
||||
@@ -850,6 +850,11 @@
|
||||
"allowed_cidrs": "Allowed Network CIDRs",
|
||||
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"allow_localhost_bypass": "Allow Localhost Bypass",
|
||||
"allow_localhost_bypass_hint": "When enabled, localhost requests are allowed even when they do not match the allowed CIDRs. Disable this when the launcher is behind a same-host proxy.",
|
||||
"trusted_proxy_cidrs": "Trusted Proxy CIDRs",
|
||||
"trusted_proxy_cidrs_hint": "Only proxies from these CIDR ranges may provide client IP information such as X-Forwarded-For. Leave empty to ignore proxy client IP headers.",
|
||||
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
|
||||
"evolution_section_hint": "Let the agent learn from completed turns and prepare skill improvements.",
|
||||
"evolution_enabled": "Enable Evolution",
|
||||
"evolution_enabled_hint": "Record learning data for completed turns. Draft and apply modes can also generate skill updates.",
|
||||
|
||||
@@ -850,6 +850,11 @@
|
||||
"allowed_cidrs": "CIDRs de Rede Permitidos",
|
||||
"allowed_cidrs_hint": "Apenas clientes destes intervalos CIDR podem acessar o serviço. Um por linha ou separados por vírgula. Deixe vazio para permitir todos.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"allow_localhost_bypass": "Permitir Bypass por Localhost",
|
||||
"allow_localhost_bypass_hint": "Quando habilitado, requisições localhost são permitidas mesmo sem corresponder aos CIDRs permitidos. Desabilite quando o launcher estiver atrás de um proxy no mesmo host.",
|
||||
"trusted_proxy_cidrs": "CIDRs de Proxy Confiáveis",
|
||||
"trusted_proxy_cidrs_hint": "Apenas proxies destes intervalos CIDR podem informar dados do IP do cliente, como X-Forwarded-For. Deixe vazio para ignorar cabeçalhos de IP do cliente vindos de proxy.",
|
||||
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
|
||||
"evolution_section_hint": "Permite que o agente aprenda com turnos concluídos e prepare melhorias de skills.",
|
||||
"evolution_enabled": "Habilitar Evolução",
|
||||
"evolution_enabled_hint": "Registrar dados de aprendizado para turnos concluídos. Os modos Draft e Apply também podem gerar atualizações de skills.",
|
||||
|
||||
@@ -850,6 +850,11 @@
|
||||
"allowed_cidrs": "允许访问网段",
|
||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"allow_localhost_bypass": "允许 localhost 绕过",
|
||||
"allow_localhost_bypass_hint": "启用后,即使 localhost 请求不匹配允许访问网段,也会被放行。如果启动器位于同主机代理之后,建议关闭。",
|
||||
"trusted_proxy_cidrs": "可信代理网段",
|
||||
"trusted_proxy_cidrs_hint": "仅这些 CIDR 网段内的代理可以传递客户端 IP 信息,例如 X-Forwarded-For。留空表示忽略代理传递的客户端 IP 头。",
|
||||
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
|
||||
"evolution_section_hint": "让 Agent 从已完成的回合中学习,并准备技能改进。",
|
||||
"evolution_enabled": "启用自进化",
|
||||
"evolution_enabled_hint": "记录已完成回合的学习数据。Draft 和 Apply 模式还可以生成技能更新。",
|
||||
|
||||
Reference in New Issue
Block a user