feat(web): harden launcher access control
This commit is contained in:
parent
f8462855d8
commit
52ab6c4694
18 changed files with 475 additions and 68 deletions
|
|
@ -81,6 +81,8 @@ That file currently stores:
|
||||||
- `port`
|
- `port`
|
||||||
- `public`
|
- `public`
|
||||||
- `allowed_cidrs`
|
- `allowed_cidrs`
|
||||||
|
- `allow_localhost_bypass`
|
||||||
|
- `trusted_proxy_cidrs`
|
||||||
|
|
||||||
If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
|
If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
|
||||||
If they are omitted, stored launcher settings are used.
|
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
|
- the launcher still protects the dashboard with password login
|
||||||
- optional `allowed_cidrs` can restrict which client IP ranges may connect
|
- 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
|
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
|
||||||
|
|
||||||
## Build And Run
|
## Build And Run
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type launcherConfigPayload struct {
|
type launcherConfigPayload struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Public bool `json:"public"`
|
Public bool `json:"public"`
|
||||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
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) {
|
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||||
|
|
@ -29,9 +39,11 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
|
||||||
port = launcherconfig.DefaultPort
|
port = launcherconfig.DefaultPort
|
||||||
}
|
}
|
||||||
return launcherconfig.Config{
|
return launcherconfig.Config{
|
||||||
Port: port,
|
Port: port,
|
||||||
Public: h.serverPublic,
|
Public: h.serverPublic,
|
||||||
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
Public: cfg.Public,
|
Public: cfg.Public,
|
||||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
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) {
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -69,6 +83,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||||
cfg.Port = payload.Port
|
cfg.Port = payload.Port
|
||||||
cfg.Public = payload.Public
|
cfg.Public = payload.Public
|
||||||
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
|
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
|
||||||
|
if payload.AllowLocalhostBypass != nil {
|
||||||
|
cfg.AllowLocalhostBypass = *payload.AllowLocalhostBypass
|
||||||
|
}
|
||||||
|
cfg.TrustedProxyCIDRs = append([]string(nil), payload.TrustedProxyCIDRs...)
|
||||||
cfg.LegacyLauncherToken = ""
|
cfg.LegacyLauncherToken = ""
|
||||||
if err := launcherconfig.Validate(cfg); err != nil {
|
if err := launcherconfig.Validate(cfg); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
Public: cfg.Public,
|
Public: cfg.Public,
|
||||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
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")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"})
|
h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"})
|
||||||
|
h.SetServerAccessOptions(false, []string{"10.0.0.0/8"})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
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" {
|
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)
|
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) {
|
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||||
|
|
@ -60,7 +67,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||||
http.MethodPut,
|
http.MethodPut,
|
||||||
"/api/system/launcher-config",
|
"/api/system/launcher-config",
|
||||||
strings.NewReader(
|
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")
|
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" {
|
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)
|
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) {
|
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())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,35 @@ import (
|
||||||
|
|
||||||
// Handler serves HTTP API requests.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
serverPort int
|
serverPort int
|
||||||
serverPublic bool
|
serverPublic bool
|
||||||
serverPublicExplicit bool
|
serverPublicExplicit bool
|
||||||
serverHostInput string
|
serverHostInput string
|
||||||
serverHostExplicit bool
|
serverHostExplicit bool
|
||||||
serverCIDRs []string
|
serverCIDRs []string
|
||||||
debug bool
|
serverAllowLocalhostBypass bool
|
||||||
oauthMu sync.Mutex
|
serverTrustedProxyCIDRs []string
|
||||||
oauthFlows map[string]*oauthFlow
|
debug bool
|
||||||
oauthState map[string]string
|
oauthMu sync.Mutex
|
||||||
weixinMu sync.Mutex
|
oauthFlows map[string]*oauthFlow
|
||||||
weixinFlows map[string]*weixinFlow
|
oauthState map[string]string
|
||||||
wecomMu sync.Mutex
|
weixinMu sync.Mutex
|
||||||
wecomFlows map[string]*wecomFlow
|
weixinFlows map[string]*weixinFlow
|
||||||
|
wecomMu sync.Mutex
|
||||||
|
wecomFlows map[string]*wecomFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
func NewHandler(configPath string) *Handler {
|
func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
serverPort: launcherconfig.DefaultPort,
|
serverPort: launcherconfig.DefaultPort,
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
serverAllowLocalhostBypass: launcherconfig.Default().AllowLocalhostBypass,
|
||||||
oauthState: make(map[string]string),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
weixinFlows: make(map[string]*weixinFlow),
|
oauthState: make(map[string]string),
|
||||||
wecomFlows: make(map[string]*wecomFlow),
|
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...)
|
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.
|
// SetServerBindHost stores the launcher's effective bind host.
|
||||||
// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
|
// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
|
||||||
func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
|
func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ type Config struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Public bool `json:"public"`
|
Public bool `json:"public"`
|
||||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
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"`
|
DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
|
||||||
// LegacyLauncherToken is read only for one-time migration from the removed
|
// 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.
|
// 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.
|
// Default returns default launcher settings.
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{Port: DefaultPort, Public: false}
|
return Config{Port: DefaultPort, Public: false, AllowLocalhostBypass: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if launcher settings are valid.
|
// Validate checks if launcher settings are valid.
|
||||||
|
|
@ -44,6 +46,11 @@ func Validate(cfg Config) error {
|
||||||
return fmt.Errorf("invalid CIDR %q", cidr)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +102,7 @@ func Load(path string, fallback Config) (Config, error) {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||||
|
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
|
||||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||||
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
|
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
|
|
@ -106,6 +114,7 @@ func Load(path string, fallback Config) (Config, error) {
|
||||||
// Save writes launcher settings to disk.
|
// Save writes launcher settings to disk.
|
||||||
func Save(path string, cfg Config) error {
|
func Save(path string, cfg Config) error {
|
||||||
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||||
|
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
|
||||||
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
|
||||||
cfg.LegacyLauncherToken = ""
|
cfg.LegacyLauncherToken = ""
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import (
|
||||||
|
|
||||||
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
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)
|
got, err := Load(path, fallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -18,6 +20,9 @@ func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||||
if got.Port != fallback.Port || got.Public != fallback.Public {
|
if got.Port != fallback.Port || got.Public != fallback.Public {
|
||||||
t.Fatalf("Load() = %+v, want %+v", got, fallback)
|
t.Fatalf("Load() = %+v, want %+v", got, fallback)
|
||||||
}
|
}
|
||||||
|
if !got.AllowLocalhostBypass {
|
||||||
|
t.Fatal("allow_localhost_bypass = false, want true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||||
|
|
@ -27,6 +32,8 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||||
Port: 18080,
|
Port: 18080,
|
||||||
Public: true,
|
Public: true,
|
||||||
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
|
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",
|
DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
|
||||||
LegacyLauncherToken: "legacy-token-should-not-persist",
|
LegacyLauncherToken: "legacy-token-should-not-persist",
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +48,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||||
if got.Port != want.Port || got.Public != want.Public {
|
if got.Port != want.Port || got.Public != want.Public {
|
||||||
t.Fatalf("Load() = %+v, want %+v", got, want)
|
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 {
|
if got.DashboardPasswordHash != want.DashboardPasswordHash {
|
||||||
t.Fatalf("dashboard_password_hash = %q, want %q", 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])
|
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)
|
stat, err := os.Stat(path)
|
||||||
if err != nil {
|
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) {
|
func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||||
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
||||||
t.Fatal("Validate() expected error for port 0")
|
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) {
|
func TestNormalizeCIDRs(t *testing.T) {
|
||||||
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
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"}
|
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||||
|
|
|
||||||
|
|
@ -585,13 +585,21 @@ func main() {
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||||
}
|
}
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
|
apiHandler.SetServerAccessOptions(
|
||||||
|
launcherCfg.AllowLocalhostBypass,
|
||||||
|
launcherCfg.TrustedProxyCIDRs,
|
||||||
|
)
|
||||||
apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
|
apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
registerEmbedRoutes(mux)
|
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 {
|
if err != nil {
|
||||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,42 +7,77 @@ import (
|
||||||
"strings"
|
"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.
|
// 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.
|
// Empty CIDR list means no restriction.
|
||||||
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
func IPAllowlist(cfg IPAllowlistConfig, next http.Handler) (http.Handler, error) {
|
||||||
if len(allowedCIDRs) == 0 {
|
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
|
return next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
for _, cidr := range allowedCIDRs {
|
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)
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||||
}
|
}
|
||||||
nets = append(nets, ipNet)
|
nets = append(nets, ipNet)
|
||||||
}
|
}
|
||||||
|
return nets, nil
|
||||||
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
func containsIP(nets []*net.IPNet, ip net.IP) bool {
|
||||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
for _, ipNet := range nets {
|
||||||
if ip == nil {
|
if ipNet.Contains(ip) {
|
||||||
rejectByPolicy(w, r)
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if ip.IsLoopback() {
|
}
|
||||||
next.ServeHTTP(w, r)
|
return false
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, ipNet := range nets {
|
|
||||||
if ipNet.Contains(ip) {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectByPolicy(w, r)
|
|
||||||
}), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||||
|
|
@ -53,6 +88,21 @@ func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||||
return net.ParseIP(host)
|
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) {
|
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -25,7 +25,9 @@ func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPAllowlist_RejectsOutsideCIDR(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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -43,7 +45,9 @@ func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPAllowlist_AllowsInsideCIDR(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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -60,8 +64,11 @@ func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
|
func TestIPAllowlist_AllowsLoopbackWhenBypassEnabled(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"},
|
||||||
|
AllowLocalhostBypass: true,
|
||||||
|
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
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) {
|
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 {
|
if err == nil {
|
||||||
t.Fatal("IPAllowlist() expected error for invalid CIDR")
|
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
|
port: number
|
||||||
public: boolean
|
public: boolean
|
||||||
allowed_cidrs: string[]
|
allowed_cidrs: string[]
|
||||||
|
allow_localhost_bypass: boolean
|
||||||
|
trusted_proxy_cidrs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemVersionInfo {
|
export interface SystemVersionInfo {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,10 @@ export function ConfigPage() {
|
||||||
port: String(launcherConfig.port),
|
port: String(launcherConfig.port),
|
||||||
publicAccess: launcherConfig.public,
|
publicAccess: launcherConfig.public,
|
||||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||||
|
allowLocalhostBypass: launcherConfig.allow_localhost_bypass ?? true,
|
||||||
|
trustedProxyCIDRsText: (launcherConfig.trusted_proxy_cidrs ?? []).join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
dashboardPassword: "",
|
dashboardPassword: "",
|
||||||
dashboardPasswordConfirm: "",
|
dashboardPasswordConfirm: "",
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +186,11 @@ export function ConfigPage() {
|
||||||
const launcherSettingsDirty =
|
const launcherSettingsDirty =
|
||||||
launcherForm.port !== launcherBaseline.port ||
|
launcherForm.port !== launcherBaseline.port ||
|
||||||
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
|
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
|
||||||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText
|
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText ||
|
||||||
|
launcherForm.allowLocalhostBypass !==
|
||||||
|
launcherBaseline.allowLocalhostBypass ||
|
||||||
|
launcherForm.trustedProxyCIDRsText !==
|
||||||
|
launcherBaseline.trustedProxyCIDRsText
|
||||||
const launcherPasswordDirty =
|
const launcherPasswordDirty =
|
||||||
launcherForm.dashboardPassword.trim() !== "" ||
|
launcherForm.dashboardPassword.trim() !== "" ||
|
||||||
launcherForm.dashboardPasswordConfirm.trim() !== ""
|
launcherForm.dashboardPasswordConfirm.trim() !== ""
|
||||||
|
|
@ -637,10 +645,15 @@ export function ConfigPage() {
|
||||||
max: 65535,
|
max: 65535,
|
||||||
})
|
})
|
||||||
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
||||||
|
const trustedProxyCIDRs = parseCIDRText(
|
||||||
|
launcherForm.trustedProxyCIDRsText,
|
||||||
|
)
|
||||||
const savedLauncherConfig = await updateLauncherConfig({
|
const savedLauncherConfig = await updateLauncherConfig({
|
||||||
port,
|
port,
|
||||||
public: launcherForm.publicAccess,
|
public: launcherForm.publicAccess,
|
||||||
allowed_cidrs: allowedCIDRs,
|
allowed_cidrs: allowedCIDRs,
|
||||||
|
allow_localhost_bypass: launcherForm.allowLocalhostBypass,
|
||||||
|
trusted_proxy_cidrs: trustedProxyCIDRs,
|
||||||
})
|
})
|
||||||
const parsedLauncher: LauncherForm = {
|
const parsedLauncher: LauncherForm = {
|
||||||
port: String(savedLauncherConfig.port),
|
port: String(savedLauncherConfig.port),
|
||||||
|
|
@ -648,6 +661,11 @@ export function ConfigPage() {
|
||||||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||||
"\n",
|
"\n",
|
||||||
),
|
),
|
||||||
|
allowLocalhostBypass:
|
||||||
|
savedLauncherConfig.allow_localhost_bypass ?? true,
|
||||||
|
trustedProxyCIDRsText: (
|
||||||
|
savedLauncherConfig.trusted_proxy_cidrs ?? []
|
||||||
|
).join("\n"),
|
||||||
dashboardPassword: "",
|
dashboardPassword: "",
|
||||||
dashboardPasswordConfirm: "",
|
dashboardPasswordConfirm: "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1243,6 +1243,34 @@ export function LauncherSection({
|
||||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
</ConfigSectionCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export interface LauncherForm {
|
||||||
port: string
|
port: string
|
||||||
publicAccess: boolean
|
publicAccess: boolean
|
||||||
allowedCIDRsText: string
|
allowedCIDRsText: string
|
||||||
|
allowLocalhostBypass: boolean
|
||||||
|
trustedProxyCIDRsText: string
|
||||||
dashboardPassword: string
|
dashboardPassword: string
|
||||||
dashboardPasswordConfirm: string
|
dashboardPasswordConfirm: string
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +165,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||||
port: "18800",
|
port: "18800",
|
||||||
publicAccess: false,
|
publicAccess: false,
|
||||||
allowedCIDRsText: "",
|
allowedCIDRsText: "",
|
||||||
|
allowLocalhostBypass: true,
|
||||||
|
trustedProxyCIDRsText: "",
|
||||||
dashboardPassword: "",
|
dashboardPassword: "",
|
||||||
dashboardPasswordConfirm: "",
|
dashboardPasswordConfirm: "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -849,6 +849,11 @@
|
||||||
"allowed_cidrs": "অনুমোদিত নেটওয়ার্ক CIDR",
|
"allowed_cidrs": "অনুমোদিত নেটওয়ার্ক CIDR",
|
||||||
"allowed_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের ক্লায়েন্টরা সার্ভিস অ্যাক্সেস করতে পারে। প্রতি লাইনে একটি বা কমা দিয়ে আলাদা। সবাইকে অনুমতি দিতে খালি রাখুন।",
|
"allowed_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের ক্লায়েন্টরা সার্ভিস অ্যাক্সেস করতে পারে। প্রতি লাইনে একটি বা কমা দিয়ে আলাদা। সবাইকে অনুমতি দিতে খালি রাখুন।",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"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_section_hint": "এজেন্টকে সম্পন্ন টার্ন থেকে শিখতে দিন এবং দক্ষতার উন্নতি প্রস্তুত করুন।",
|
||||||
"evolution_enabled": "ইভোলিউশন সক্ষম করুন",
|
"evolution_enabled": "ইভোলিউশন সক্ষম করুন",
|
||||||
"evolution_enabled_hint": "সম্পন্ন টার্নের জন্য শেখার ডেটা রেকর্ড করুন। ড্রাফ্ট এবং অ্যাপ্লাই মোড দক্ষতা আপডেটও তৈরি করতে পারে।",
|
"evolution_enabled_hint": "সম্পন্ন টার্নের জন্য শেখার ডেটা রেকর্ড করুন। ড্রাফ্ট এবং অ্যাপ্লাই মোড দক্ষতা আপডেটও তৈরি করতে পারে।",
|
||||||
|
|
|
||||||
|
|
@ -848,6 +848,11 @@
|
||||||
"allowed_cidrs": "Povolené síťové CIDRy",
|
"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_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",
|
"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_section_hint": "Nechte agenta učit se z dokončených tahů a připravovat vylepšení dovedností.",
|
||||||
"evolution_enabled": "Povolit evoluci",
|
"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í.",
|
"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": "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_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",
|
"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_section_hint": "Let the agent learn from completed turns and prepare skill improvements.",
|
||||||
"evolution_enabled": "Enable Evolution",
|
"evolution_enabled": "Enable Evolution",
|
||||||
"evolution_enabled_hint": "Record learning data for completed turns. Draft and apply modes can also generate skill updates.",
|
"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": "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_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",
|
"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_section_hint": "Permite que o agente aprenda com turnos concluídos e prepare melhorias de skills.",
|
||||||
"evolution_enabled": "Habilitar Evolução",
|
"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.",
|
"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": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"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_section_hint": "让 Agent 从已完成的回合中学习,并准备技能改进。",
|
||||||
"evolution_enabled": "启用自进化",
|
"evolution_enabled": "启用自进化",
|
||||||
"evolution_enabled_hint": "记录已完成回合的学习数据。Draft 和 Apply 模式还可以生成技能更新。",
|
"evolution_enabled_hint": "记录已完成回合的学习数据。Draft 和 Apply 模式还可以生成技能更新。",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue