diff --git a/backend/internal/api/settings_handler.go b/backend/internal/api/settings_handler.go index 1bc9a12..5a8b97e 100644 --- a/backend/internal/api/settings_handler.go +++ b/backend/internal/api/settings_handler.go @@ -328,6 +328,14 @@ func (h *SettingsHandler) saveAppSettings(w http.ResponseWriter, r *http.Request return } payload = normalizeAppSettings(payload) + if err := validateLocalProxyHost(payload.ProxyHost); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateLANShareIPWhitelist(payload.LANShareIPWhitelist); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if proxyEndpointChanged(current, payload) { if err := h.updateEnabledProxyConfig(payload); err != nil { http.Error(w, err.Error(), http.StatusConflict) @@ -341,6 +349,43 @@ func (h *SettingsHandler) saveAppSettings(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, h.appSettings()) } +func validateLocalProxyHost(host string) error { + normalized := strings.TrimSpace(host) + if normalized == "" { + return fmt.Errorf("proxy_host is required") + } + if strings.EqualFold(normalized, "localhost") { + return nil + } + ip := net.ParseIP(normalized) + if ip != nil && ip.IsLoopback() { + return nil + } + return fmt.Errorf("proxy_host %q is not local-only, use 127.0.0.1 / localhost / ::1", host) +} + +func validateLANShareIPWhitelist(raw string) error { + for _, entry := range strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) { + normalized := strings.TrimSpace(entry) + if normalized == "" { + continue + } + if strings.EqualFold(normalized, "localhost") { + continue + } + if ip := net.ParseIP(normalized); ip != nil { + continue + } + if _, _, err := net.ParseCIDR(normalized); err == nil { + continue + } + return fmt.Errorf("lan_share_ip_whitelist entry %q is invalid, use IP or CIDR", normalized) + } + return nil +} + func (h *SettingsHandler) getFailoverQueue(w http.ResponseWriter) { if h.settings == nil { writeJSON(w, http.StatusOK, []int64{}) @@ -600,6 +645,7 @@ func normalizeAppSettings(value settings.AppSettings) settings.AppSettings { if value.ProxyPort <= 0 { value.ProxyPort = defaults.ProxyPort } + value.LANShareIPWhitelist = strings.TrimSpace(value.LANShareIPWhitelist) switch value.UpstreamProxyMode { case settings.UpstreamProxyModeSystem, settings.UpstreamProxyModeDirect, settings.UpstreamProxyModeManual: default: diff --git a/backend/internal/api/settings_handler_test.go b/backend/internal/api/settings_handler_test.go index 8e5aa8a..d9455cb 100644 --- a/backend/internal/api/settings_handler_test.go +++ b/backend/internal/api/settings_handler_test.go @@ -48,6 +48,8 @@ func TestSettingsHandlerGetAndPutAppSettings(t *testing.T) { "usage_request_timeout_seconds": 18, "proxy_host": "localhost", "proxy_port": 15721, + "lan_share_enabled": true, + "lan_share_ip_whitelist": "192.168.1.10\n192.168.1.0/24", "auto_failover_enabled": true, "auto_backup_interval_hours": 12, "backup_retention_count": 7, @@ -66,11 +68,51 @@ func TestSettingsHandlerGetAndPutAppSettings(t *testing.T) { if err != nil { t.Fatalf("GetAppSettings returned error: %v", err) } - if !stored.LaunchAtLogin || !stored.SilentStart || stored.CloseToTray || stored.ShowProxySwitchOnHome || stored.ShowHomeUpdateIndicator || stored.UsageRequestTimeoutSeconds != 18 || stored.ProxyHost != "localhost" || stored.ProxyPort != 15721 || !stored.AutoFailoverEnabled || stored.AutoBackupIntervalHours != 12 || stored.BackupRetentionCount != 7 || stored.Language != "en-US" || stored.ThemeMode != "dark" { + if !stored.LaunchAtLogin || !stored.SilentStart || stored.CloseToTray || stored.ShowProxySwitchOnHome || stored.ShowHomeUpdateIndicator || stored.UsageRequestTimeoutSeconds != 18 || stored.ProxyHost != "localhost" || stored.ProxyPort != 15721 || !stored.LANShareEnabled || stored.LANShareIPWhitelist != "192.168.1.10\n192.168.1.0/24" || !stored.AutoFailoverEnabled || stored.AutoBackupIntervalHours != 12 || stored.BackupRetentionCount != 7 || stored.Language != "en-US" || stored.ThemeMode != "dark" { t.Fatalf("stored settings = %+v, want updated values", stored) } } +func TestSettingsHandlerRejectsInvalidLANShareWhitelist(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + handler, _ := newSettingsHandler(t) + + body := strings.NewReader(`{ + "launch_at_login": false, + "silent_start": false, + "close_to_tray": true, + "show_proxy_switch_on_home": true, + "show_home_update_indicator": true, + "status_refresh_interval_seconds": 60, + "usage_request_timeout_seconds": 15, + "proxy_host": "127.0.0.1", + "proxy_port": 6789, + "lan_share_enabled": true, + "lan_share_ip_whitelist": "bad-entry", + "upstream_proxy_mode": "system", + "upstream_proxy_url": "", + "upstream_proxy_username": "", + "upstream_proxy_password": "", + "auto_failover_enabled": true, + "auto_backup_interval_hours": 24, + "backup_retention_count": 10, + "language": "zh-CN", + "theme_mode": "system" + }`) + req := httptest.NewRequest(http.MethodPut, "/settings/app", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT /settings/app status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "lan_share_ip_whitelist") { + t.Fatalf("response body = %q, want lan_share_ip_whitelist validation error", rec.Body.String()) + } +} + func TestSettingsHandlerGetAndPutFailoverQueue(t *testing.T) { handler, repo := newSettingsHandler(t) @@ -609,6 +651,52 @@ func TestSettingsHandlerUpdatingProxyAddressRewritesEnabledConfig(t *testing.T) assertFileContains(t, filepath.Join(home, ".codex", "config.toml"), `base_url = "http://localhost:15721/ai-router/api"`) } +func TestSettingsHandlerRejectsNonLocalProxyHost(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + handler, repo := newSettingsHandler(t) + + body := strings.NewReader(`{ + "launch_at_login": false, + "silent_start": false, + "close_to_tray": true, + "show_proxy_switch_on_home": true, + "show_home_update_indicator": true, + "status_refresh_interval_seconds": 60, + "usage_request_timeout_seconds": 15, + "proxy_host": "192.168.1.24", + "proxy_port": 6789, + "upstream_proxy_mode": "system", + "upstream_proxy_url": "", + "upstream_proxy_username": "", + "upstream_proxy_password": "", + "auto_failover_enabled": true, + "auto_backup_interval_hours": 24, + "backup_retention_count": 10, + "language": "zh-CN", + "theme_mode": "system" + }`) + req := httptest.NewRequest(http.MethodPut, "/settings/app", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT /settings/app status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "proxy_host") { + t.Fatalf("response body = %q, want proxy_host validation error", rec.Body.String()) + } + + stored, err := repo.GetAppSettings() + if err != nil { + t.Fatalf("GetAppSettings returned error: %v", err) + } + if stored.ProxyHost != settings.DefaultAppSettings().ProxyHost { + t.Fatalf("stored proxy_host = %q, want %q", stored.ProxyHost, settings.DefaultAppSettings().ProxyHost) + } +} + func TestSettingsHandlerProxyDisableDetachesEvenWhenConfigChanged(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index d347242..24e801a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -6,9 +6,11 @@ import ( "errors" "fmt" "log" + "net" "net/http" "path/filepath" "runtime/debug" + "strings" "sync" "time" @@ -184,7 +186,7 @@ func NewApp(_ context.Context, cfg Config) (*App, error) { } http.NotFound(w, r) }) - mux.Handle("/ai-router/api/", withCORS(http.StripPrefix("/ai-router/api", apiMux))) + mux.Handle("/ai-router/api/", withCORS(withLANShareAccessControl(settingsRepo, http.StripPrefix("/ai-router/api", apiMux)))) appCtx, cancel := context.WithCancel(context.Background()) app := &App{listenAddr: cfg.ListenAddr, handler: mux, store: store, cancel: cancel} @@ -315,6 +317,85 @@ func withCORS(next http.Handler) http.Handler { }) } +func withLANShareAccessControl(repo settings.ReadRepository, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if repo == nil { + next.ServeHTTP(w, r) + return + } + + appSettings, err := repo.GetAppSettings() + if err != nil || !appSettings.LANShareEnabled { + next.ServeHTTP(w, r) + return + } + + remoteIP, err := remoteIPFromAddr(r.RemoteAddr) + if err != nil { + http.Error(w, "invalid remote address", http.StatusForbidden) + return + } + if remoteIP.IsLoopback() { + next.ServeHTTP(w, r) + return + } + allowed, err := ipAllowedByWhitelist(remoteIP, appSettings.LANShareIPWhitelist) + if err != nil { + log.Printf("lan share whitelist parse failed: %v", err) + http.Error(w, "lan share whitelist is invalid", http.StatusForbidden) + return + } + if !allowed { + http.Error(w, "remote address is not allowed by lan share whitelist", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func remoteIPFromAddr(addr string) (net.IP, error) { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return nil, err + } + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("parse remote ip: %q", host) + } + return ip, nil +} + +func ipAllowedByWhitelist(ip net.IP, raw string) (bool, error) { + if strings.TrimSpace(raw) == "" { + return true, nil + } + for _, entry := range strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) { + normalized := strings.TrimSpace(entry) + if normalized == "" { + continue + } + if strings.EqualFold(normalized, "localhost") { + continue + } + if allowedIP := net.ParseIP(normalized); allowedIP != nil { + if allowedIP.Equal(ip) { + return true, nil + } + continue + } + _, network, err := net.ParseCIDR(normalized) + if err != nil { + return false, err + } + if network.Contains(ip) { + return true, nil + } + } + return false, nil +} + func (a *App) ListenAddr() string { return a.listenAddr } diff --git a/backend/internal/bootstrap/lan_share_access_test.go b/backend/internal/bootstrap/lan_share_access_test.go new file mode 100644 index 0000000..ed94adb --- /dev/null +++ b/backend/internal/bootstrap/lan_share_access_test.go @@ -0,0 +1,107 @@ +package bootstrap + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gcssloop/codex-router/backend/internal/settings" + sqlitestore "github.com/gcssloop/codex-router/backend/internal/store/sqlite" +) + +func TestLANShareAccessControlAllowsLoopbackEvenWithWhitelist(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + repo := settings.NewSQLiteRepository(store.DB()) + appSettings := settings.DefaultAppSettings() + appSettings.LANShareEnabled = true + appSettings.LANShareIPWhitelist = "192.168.1.10" + if err := repo.SaveAppSettings(appSettings); err != nil { + t.Fatalf("SaveAppSettings returned error: %v", err) + } + + handler := withLANShareAccessControl(repo, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "/ai-router/api/settings/app", nil) + req.RemoteAddr = "127.0.0.1:54321" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } +} + +func TestLANShareAccessControlBlocksNonWhitelistedRemoteAddr(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + repo := settings.NewSQLiteRepository(store.DB()) + appSettings := settings.DefaultAppSettings() + appSettings.LANShareEnabled = true + appSettings.LANShareIPWhitelist = "192.168.1.10" + if err := repo.SaveAppSettings(appSettings); err != nil { + t.Fatalf("SaveAppSettings returned error: %v", err) + } + + handler := withLANShareAccessControl(repo, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "/ai-router/api/settings/app", nil) + req.RemoteAddr = "192.168.1.11:54321" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestLANShareAccessControlAllowsAllWhenWhitelistEmpty(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + repo := settings.NewSQLiteRepository(store.DB()) + appSettings := settings.DefaultAppSettings() + appSettings.LANShareEnabled = true + appSettings.LANShareIPWhitelist = "" + if err := repo.SaveAppSettings(appSettings); err != nil { + t.Fatalf("SaveAppSettings returned error: %v", err) + } + + handler := withLANShareAccessControl(repo, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "/ai-router/api/settings/app", nil) + req.RemoteAddr = "192.168.1.11:54321" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7f301e1..6dc975c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -71,9 +71,9 @@ func validateLocalListenAddr(addr string) error { } normalized := strings.TrimSpace(host) switch normalized { - case "127.0.0.1", "localhost", "::1": + case "127.0.0.1", "localhost", "::1", "0.0.0.0", "::": return nil default: - return fmt.Errorf("listen addr %q is not local-only, use 127.0.0.1/localhost/::1", addr) + return fmt.Errorf("listen addr %q is invalid, use 127.0.0.1/localhost/::1 for local-only or 0.0.0.0/:: for LAN sharing", addr) } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index c868b76..5eea007 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -62,11 +62,15 @@ func TestLoadParsesValuesFromEnv(t *testing.T) { } } -func TestLoadRejectsNonLocalListenAddr(t *testing.T) { +func TestLoadAllowsLANShareListenAddr(t *testing.T) { t.Setenv("CODEX_ROUTER_LISTEN_ADDR", "0.0.0.0:6789") + t.Setenv("CODEX_ROUTER_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef") - _, err := config.Load() - if err == nil { - t.Fatal("Load returned nil error, want localhost validation error") + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.ListenAddr != "0.0.0.0:6789" { + t.Fatalf("ListenAddr = %q, want %q", cfg.ListenAddr, "0.0.0.0:6789") } } diff --git a/backend/internal/settings/repository.go b/backend/internal/settings/repository.go index b288779..e4a3c0d 100644 --- a/backend/internal/settings/repository.go +++ b/backend/internal/settings/repository.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "net" "strconv" "strings" ) @@ -29,6 +30,8 @@ type AppSettings struct { UsageRequestTimeoutSeconds int `json:"usage_request_timeout_seconds"` ProxyHost string `json:"proxy_host"` ProxyPort int `json:"proxy_port"` + LANShareEnabled bool `json:"lan_share_enabled"` + LANShareIPWhitelist string `json:"lan_share_ip_whitelist"` UpstreamProxyMode string `json:"upstream_proxy_mode"` UpstreamProxyURL string `json:"upstream_proxy_url"` UpstreamProxyUsername string `json:"upstream_proxy_username"` @@ -70,6 +73,8 @@ func DefaultAppSettings() AppSettings { UsageRequestTimeoutSeconds: 15, ProxyHost: "127.0.0.1", ProxyPort: 6789, + LANShareEnabled: false, + LANShareIPWhitelist: "", UpstreamProxyMode: UpstreamProxyModeSystem, AutoFailoverEnabled: true, AutoBackupIntervalHours: 24, @@ -81,7 +86,7 @@ func DefaultAppSettings() AppSettings { func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { row := r.db.QueryRow( - `SELECT launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, + `SELECT launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, lan_share_enabled, lan_share_ip_whitelist, upstream_proxy_mode, upstream_proxy_url, upstream_proxy_username, upstream_proxy_password, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, language, theme_mode, provider_pricing, account_pricing FROM app_settings WHERE id = 1`, @@ -96,6 +101,8 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { var usageRequestTimeoutSeconds int var proxyHost string var proxyPort int + var lanShareEnabled int + var lanShareIPWhitelist string var upstreamProxyMode string var upstreamProxyURL string var upstreamProxyUsername string @@ -118,6 +125,8 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { &usageRequestTimeoutSeconds, &proxyHost, &proxyPort, + &lanShareEnabled, + &lanShareIPWhitelist, &upstreamProxyMode, &upstreamProxyURL, &upstreamProxyUsername, @@ -155,6 +164,8 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { UsageRequestTimeoutSeconds: usageRequestTimeoutSeconds, ProxyHost: proxyHost, ProxyPort: proxyPort, + LANShareEnabled: lanShareEnabled == 1, + LANShareIPWhitelist: lanShareIPWhitelist, UpstreamProxyMode: upstreamProxyMode, UpstreamProxyURL: upstreamProxyURL, UpstreamProxyUsername: upstreamProxyUsername, @@ -181,10 +192,10 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { } _, err = r.db.Exec( `INSERT INTO app_settings ( - id, launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, + id, launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, lan_share_enabled, lan_share_ip_whitelist, upstream_proxy_mode, upstream_proxy_url, upstream_proxy_username, upstream_proxy_password, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, language, theme_mode, provider_pricing, account_pricing, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET launch_at_login = excluded.launch_at_login, silent_start = excluded.silent_start, @@ -195,6 +206,8 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { usage_request_timeout_seconds = excluded.usage_request_timeout_seconds, proxy_host = excluded.proxy_host, proxy_port = excluded.proxy_port, + lan_share_enabled = excluded.lan_share_enabled, + lan_share_ip_whitelist = excluded.lan_share_ip_whitelist, upstream_proxy_mode = excluded.upstream_proxy_mode, upstream_proxy_url = excluded.upstream_proxy_url, upstream_proxy_username = excluded.upstream_proxy_username, @@ -217,6 +230,8 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { value.UsageRequestTimeoutSeconds, value.ProxyHost, value.ProxyPort, + boolToInt(value.LANShareEnabled), + value.LANShareIPWhitelist, value.UpstreamProxyMode, value.UpstreamProxyURL, value.UpstreamProxyUsername, @@ -293,6 +308,7 @@ func sanitize(value AppSettings) AppSettings { if value.ProxyPort <= 0 { value.ProxyPort = defaults.ProxyPort } + value.LANShareIPWhitelist = sanitizeIPWhitelist(value.LANShareIPWhitelist) switch value.UpstreamProxyMode { case UpstreamProxyModeSystem, UpstreamProxyModeDirect, UpstreamProxyModeManual: default: @@ -409,3 +425,46 @@ func boolToInt(value bool) int { func sanitizeOptionalString(value string) string { return strings.TrimSpace(value) } + +func sanitizeIPWhitelist(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + seen := make(map[string]struct{}) + items := make([]string, 0) + for _, raw := range strings.FieldsFunc(value, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) { + entry := strings.TrimSpace(raw) + if entry == "" || isLoopbackWhitelistEntry(entry) { + continue + } + if _, ok := seen[entry]; ok { + continue + } + seen[entry] = struct{}{} + items = append(items, entry) + } + return strings.Join(items, "\n") +} + +func isLoopbackWhitelistEntry(value string) bool { + if strings.EqualFold(value, "localhost") { + return true + } + if ip, err := strconv.Unquote(value); err == nil { + value = ip + } + parsed := strings.TrimSpace(value) + ip := net.ParseIP(parsed) + if ip != nil { + return ip.IsLoopback() + } + if strings.Contains(parsed, "/") { + if _, cidr, err := net.ParseCIDR(parsed); err == nil && cidr.IP.IsLoopback() { + return true + } + } + return false +} diff --git a/backend/internal/settings/repository_test.go b/backend/internal/settings/repository_test.go index 432a871..38dd425 100644 --- a/backend/internal/settings/repository_test.go +++ b/backend/internal/settings/repository_test.go @@ -63,6 +63,8 @@ func TestRepositoryPersistsAppSettingsAndQueue(t *testing.T) { UsageRequestTimeoutSeconds: 22, ProxyHost: "localhost", ProxyPort: 15721, + LANShareEnabled: true, + LANShareIPWhitelist: "192.168.1.10\n192.168.1.0/24", UpstreamProxyMode: "manual", UpstreamProxyURL: "http://127.0.0.1:7890", UpstreamProxyUsername: "proxy-user", @@ -105,6 +107,34 @@ func TestRepositoryPersistsAppSettingsAndQueue(t *testing.T) { } } +func TestRepositorySanitizesLANShareWhitelist(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + repo := settings.NewSQLiteRepository(store.DB()) + value := settings.DefaultAppSettings() + value.LANShareEnabled = true + value.LANShareIPWhitelist = " 192.168.1.10 \n\n127.0.0.1\n192.168.1.10\n10.0.0.0/24 " + if err := repo.SaveAppSettings(value); err != nil { + t.Fatalf("SaveAppSettings returned error: %v", err) + } + + got, err := repo.GetAppSettings() + if err != nil { + t.Fatalf("GetAppSettings returned error: %v", err) + } + if got.LANShareIPWhitelist != "192.168.1.10\n10.0.0.0/24" { + t.Fatalf("LANShareIPWhitelist = %q, want %q", got.LANShareIPWhitelist, "192.168.1.10\n10.0.0.0/24") + } +} + func TestRepositorySanitizesUpstreamProxyMode(t *testing.T) { t.Parallel() diff --git a/backend/internal/store/sqlite/migrations.go b/backend/internal/store/sqlite/migrations.go index 4fcf4ef..3f49871 100644 --- a/backend/internal/store/sqlite/migrations.go +++ b/backend/internal/store/sqlite/migrations.go @@ -124,6 +124,8 @@ var schemaStatements = []string{ usage_request_timeout_seconds INTEGER NOT NULL DEFAULT 15, proxy_host TEXT NOT NULL DEFAULT '127.0.0.1', proxy_port INTEGER NOT NULL DEFAULT 6789, + lan_share_enabled INTEGER NOT NULL DEFAULT 0, + lan_share_ip_whitelist TEXT NOT NULL DEFAULT '', upstream_proxy_mode TEXT NOT NULL DEFAULT 'system', upstream_proxy_url TEXT NOT NULL DEFAULT '', upstream_proxy_username TEXT NOT NULL DEFAULT '', diff --git a/backend/internal/store/sqlite/store.go b/backend/internal/store/sqlite/store.go index b705b48..2a74098 100644 --- a/backend/internal/store/sqlite/store.go +++ b/backend/internal/store/sqlite/store.go @@ -125,6 +125,8 @@ func (s *Store) migrate() error { {table: "app_settings", name: "show_home_update_indicator", definition: "INTEGER NOT NULL DEFAULT 1"}, {table: "app_settings", name: "status_refresh_interval_seconds", definition: "INTEGER NOT NULL DEFAULT 60"}, {table: "app_settings", name: "usage_request_timeout_seconds", definition: "INTEGER NOT NULL DEFAULT 15"}, + {table: "app_settings", name: "lan_share_enabled", definition: "INTEGER NOT NULL DEFAULT 0"}, + {table: "app_settings", name: "lan_share_ip_whitelist", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "app_settings", name: "upstream_proxy_mode", definition: "TEXT NOT NULL DEFAULT 'system'"}, {table: "app_settings", name: "upstream_proxy_url", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "app_settings", name: "upstream_proxy_username", definition: "TEXT NOT NULL DEFAULT ''"}, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b8cc534..4de4aa9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::VecDeque; use std::io::{Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -95,6 +95,7 @@ struct DesktopSettingsCache { launch_at_login: bool, silent_start: bool, close_to_tray: bool, + lan_share_enabled: bool, proxy_host: String, proxy_port: u16, main_window_size: Option, @@ -106,6 +107,7 @@ impl Default for DesktopSettingsCache { launch_at_login: false, silent_start: false, close_to_tray: true, + lan_share_enabled: false, proxy_host: DEFAULT_PROXY_HOST.to_string(), proxy_port: DEFAULT_PROXY_PORT, main_window_size: None, @@ -120,7 +122,7 @@ impl DesktopSettingsCache { fn updated_from_app_settings(mut self, value: AppSettingsPayload) -> Self { let defaults = Self::default(); - let proxy_host = value.proxy_host.trim(); + let proxy_host = sanitize_local_proxy_host(value.proxy_host.trim(), &defaults.proxy_host); let proxy_port = if value.proxy_port == 0 { defaults.proxy_port } else { @@ -129,17 +131,22 @@ impl DesktopSettingsCache { self.launch_at_login = value.launch_at_login; self.silent_start = value.silent_start; self.close_to_tray = value.close_to_tray; - self.proxy_host = if proxy_host.is_empty() { - defaults.proxy_host - } else { - proxy_host.to_string() - }; + self.lan_share_enabled = value.lan_share_enabled; + self.proxy_host = proxy_host; self.proxy_port = proxy_port; self } fn backend_addr(&self) -> String { - format!("{}:{}", self.proxy_host, self.proxy_port) + format_host_port(&self.proxy_host, self.proxy_port) + } + + fn listen_addr(&self) -> String { + if self.lan_share_enabled { + format_host_port("0.0.0.0", self.proxy_port) + } else { + self.backend_addr() + } } fn backend_api_base(&self) -> String { @@ -147,12 +154,34 @@ impl DesktopSettingsCache { } } +fn sanitize_local_proxy_host(host: &str, fallback: &str) -> String { + let normalized = host.trim(); + if normalized.is_empty() { + return fallback.to_string(); + } + if normalized.eq_ignore_ascii_case("localhost") { + return normalized.to_string(); + } + match normalized.parse::() { + Ok(ip) if ip.is_loopback() => normalized.to_string(), + _ => fallback.to_string(), + } +} + +fn format_host_port(host: &str, port: u16) -> String { + match host.parse::() { + Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"), + _ => format!("{host}:{port}"), + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] struct AppSettingsPayload { launch_at_login: bool, silent_start: bool, close_to_tray: bool, show_proxy_switch_on_home: bool, + lan_share_enabled: bool, proxy_host: String, proxy_port: u16, auto_failover_enabled: bool, @@ -802,17 +831,23 @@ fn load_settings_cache(settings_path: &Path) -> DesktopSettingsCache { let Ok(raw) = std::fs::read_to_string(settings_path) else { return DesktopSettingsCache::default(); }; - serde_json::from_str::(&raw).unwrap_or_default() + let mut cache = serde_json::from_str::(&raw).unwrap_or_default(); + let fallback = DesktopSettingsCache::default().proxy_host; + cache.proxy_host = sanitize_local_proxy_host(&cache.proxy_host, &fallback); + if cache.proxy_port == 0 { + cache.proxy_port = DEFAULT_PROXY_PORT; + } + cache } fn persist_runtime_settings(cache: DesktopSettingsCache) -> Result { let mut runtime = DESKTOP_RUNTIME .lock() .map_err(|_| "desktop runtime lock poisoned".to_string())?; - let previous_addr = runtime.settings_cache.backend_addr(); + let previous_addr = runtime.settings_cache.listen_addr(); persist_settings_cache(&runtime.settings_path, &cache)?; runtime.settings_cache = cache.clone(); - Ok(previous_addr != cache.backend_addr()) + Ok(previous_addr != cache.listen_addr()) } fn persist_settings_cache( @@ -970,8 +1005,9 @@ fn spawn_sidecar() -> Result<(), String> { "info", "sidecar", format!( - "spawn requested path={} addr={}", + "spawn requested path={} listen_addr={} backend_addr={}", runtime.sidecar_path.display(), + runtime.settings_cache.listen_addr(), runtime.settings_cache.backend_addr() ), ); @@ -980,7 +1016,7 @@ fn spawn_sidecar() -> Result<(), String> { command .env( "CODEX_ROUTER_LISTEN_ADDR", - runtime.settings_cache.backend_addr(), + runtime.settings_cache.listen_addr(), ) .env("CODEX_ROUTER_DATABASE_PATH", runtime.database_path) .env("CODEX_ROUTER_PARENT_HEARTBEAT", "stdin") @@ -1961,27 +1997,34 @@ fn stop_sidecar_exit_watcher() { mod tests { use super::{ append_recent_desktop_log, build_launch_agent_plist, clamp_recent_log_limit, - decode_chunked_body, format_timeout_error, format_tray_title, map_backend_io_error, - parse_account_menu_id, parse_accounts_response, parse_proxy_status_response, - proxy_menu_enabled_states, resolve_main_window_size, sanitize_main_window_size, - should_attempt_sidecar_recovery, should_refresh_tray_after_action, - should_restart_sidecar_after_exit, should_retry_sidecar_request, - should_trigger_resume_recovery, sidecar_candidate_paths, sidecar_creation_flags, - sidecar_request_with_recovery, sidecar_request_with_recovery_hooks, - sidecar_resource_name, tray_icon_bytes_for_platform, tray_icon_is_template_for_platform, - update_download_progress, wait_for_backend_ready_with_probe, window_close_action, - AppSettingsPayload, DesktopLogEntry, DesktopSettingsCache, HttpResponse, - UpdateInfoPayload, UpdateManagerState, UpdateProgressPayload, UpdateStatePayload, - UpdateStatus, WindowCloseAction, WindowSizeCache, MAIN_WINDOW_MIN_HEIGHT, - MAIN_WINDOW_MIN_WIDTH, SIDECAR_MACOS_NAME, SIDECAR_WINDOWS_NAME, - TRAY_ICON_COLOR_BYTES, TRAY_ICON_TEMPLATE_BYTES, UPDATE_MANAGER, + current_backend_addr, current_settings_cache, decode_chunked_body, format_timeout_error, + format_tray_title, load_settings_cache, map_backend_io_error, parse_account_menu_id, parse_accounts_response, + parse_proxy_status_response, proxy_menu_enabled_states, resolve_main_window_size, + request_backend, restart_sidecar_and_wait_ready, + sanitize_main_window_size, should_attempt_sidecar_recovery, + should_refresh_tray_after_action, should_restart_sidecar_after_exit, + should_retry_sidecar_request, should_trigger_resume_recovery, sidecar_candidate_paths, + sidecar_creation_flags, sidecar_request_with_recovery, sidecar_request_with_recovery_hooks, + sidecar_resource_name, shutdown_sidecar_with_reason, spawn_sidecar, + tray_icon_bytes_for_platform, tray_icon_is_template_for_platform, + update_download_progress, wait_for_backend_ready, wait_for_backend_ready_with_probe, + window_close_action, AppSettingsPayload, DesktopLogEntry, DesktopRuntime, + DesktopSettingsCache, HttpResponse, UpdateInfoPayload, UpdateManagerState, + UpdateProgressPayload, UpdateStatePayload, UpdateStatus, WindowCloseAction, + WindowSizeCache, DESKTOP_RUNTIME, MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, + SIDECAR_CHILD, SIDECAR_MACOS_NAME, SIDECAR_WINDOWS_NAME, TRAY_ICON_COLOR_BYTES, + TRAY_ICON_TEMPLATE_BYTES, UPDATE_MANAGER, persist_runtime_settings, }; use std::cell::RefCell; use std::collections::VecDeque; + use std::fs; + use std::net::TcpListener; use std::path::{Path, PathBuf}; + use std::process::Command; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn parse_account_menu_id_accepts_valid_ids() { @@ -2138,6 +2181,7 @@ mod tests { assert!(!cache.launch_at_login); assert!(!cache.silent_start); assert!(cache.close_to_tray); + assert!(!cache.lan_share_enabled); assert_eq!(cache.proxy_host, "127.0.0.1"); assert_eq!(cache.proxy_port, 6789); assert_eq!(cache.main_window_size, None); @@ -2155,7 +2199,8 @@ mod tests { silent_start: true, close_to_tray: false, show_proxy_switch_on_home: false, - proxy_host: "0.0.0.0".to_string(), + lan_share_enabled: true, + proxy_host: "localhost".to_string(), proxy_port: 18080, auto_failover_enabled: true, auto_backup_interval_hours: 12, @@ -2166,9 +2211,11 @@ mod tests { assert!(cache.launch_at_login); assert!(cache.silent_start); assert!(!cache.close_to_tray); - assert_eq!(cache.proxy_host, "0.0.0.0"); + assert!(cache.lan_share_enabled); + assert_eq!(cache.proxy_host, "localhost"); assert_eq!(cache.proxy_port, 18080); - assert_eq!(cache.backend_addr(), "0.0.0.0:18080"); + assert_eq!(cache.backend_addr(), "localhost:18080"); + assert_eq!(cache.listen_addr(), "0.0.0.0:18080"); } #[test] @@ -2178,6 +2225,7 @@ mod tests { silent_start: false, close_to_tray: true, show_proxy_switch_on_home: true, + lan_share_enabled: false, proxy_host: " ".to_string(), proxy_port: 0, auto_failover_enabled: false, @@ -2190,6 +2238,60 @@ mod tests { assert_eq!(cache.proxy_port, 6789); } + #[test] + fn desktop_settings_cache_rejects_non_local_proxy_host() { + let payload = AppSettingsPayload { + launch_at_login: false, + silent_start: false, + close_to_tray: true, + show_proxy_switch_on_home: true, + lan_share_enabled: true, + proxy_host: "192.168.1.24".to_string(), + proxy_port: 18080, + auto_failover_enabled: false, + auto_backup_interval_hours: 24, + backup_retention_count: 10, + }; + + let cache = DesktopSettingsCache::default().updated_from_app_settings(payload); + assert_eq!(cache.proxy_host, "127.0.0.1"); + assert_eq!(cache.proxy_port, 18080); + assert_eq!(cache.backend_addr(), "127.0.0.1:18080"); + assert_eq!(cache.listen_addr(), "0.0.0.0:18080"); + } + + #[test] + fn load_settings_cache_sanitizes_non_local_proxy_host_from_disk() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("aigate-desktop-tests-{unique}")); + fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("desktop-settings.json"); + fs::write( + &path, + r#"{ + "launch_at_login": false, + "silent_start": false, + "close_to_tray": true, + "lan_share_enabled": true, + "proxy_host": "10.0.0.8", + "proxy_port": 16789 +}"#, + ) + .expect("write settings cache"); + + let cache = load_settings_cache(&path); + assert!(cache.lan_share_enabled); + assert_eq!(cache.proxy_host, "127.0.0.1"); + assert_eq!(cache.proxy_port, 16789); + assert_eq!(cache.backend_addr(), "127.0.0.1:16789"); + assert_eq!(cache.listen_addr(), "0.0.0.0:16789"); + let _ = fs::remove_file(&path); + let _ = fs::remove_dir(&dir); + } + #[test] fn resolved_main_window_size_uses_minimum_dimensions_by_default() { let size = resolve_main_window_size(None); @@ -2212,6 +2314,7 @@ mod tests { silent_start: false, close_to_tray: true, show_proxy_switch_on_home: true, + lan_share_enabled: false, proxy_host: "127.0.0.1".to_string(), proxy_port: 6789, auto_failover_enabled: false, @@ -2230,6 +2333,109 @@ mod tests { ); } + #[test] + #[ignore = "local smoke test that builds and launches routerd"] + fn lan_share_toggle_restarts_sidecar_without_changing_desktop_backend_addr() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!("aigate-lan-smoke-{unique}")); + fs::create_dir_all(&temp_root).expect("create smoke root"); + + let backend_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../backend"); + let sidecar_path = temp_root.join("routerd-smoke"); + let build_status = Command::new("go") + .args(["build", "-o"]) + .arg(&sidecar_path) + .arg("./cmd/routerd") + .current_dir(&backend_root) + .status() + .expect("run go build"); + assert!(build_status.success(), "go build routerd failed"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("allocate local port"); + let port = listener.local_addr().expect("listener addr").port(); + drop(listener); + + let database_path = temp_root.join("aigate.sqlite"); + let settings_path = temp_root.join("desktop-settings.json"); + + { + let mut runtime = DESKTOP_RUNTIME.lock().expect("desktop runtime lock"); + *runtime = DesktopRuntime { + sidecar_path: sidecar_path.clone(), + database_path, + settings_path, + settings_cache: DesktopSettingsCache { + proxy_host: "127.0.0.1".to_string(), + proxy_port: port, + lan_share_enabled: false, + ..DesktopSettingsCache::default() + }, + }; + } + + shutdown_sidecar_with_reason("smoke-cleanup-start"); + spawn_sidecar().expect("spawn initial sidecar"); + wait_for_backend_ready(¤t_backend_addr(), Duration::from_secs(5)) + .expect("wait for initial backend"); + let initial_response = + request_backend("GET", "/ai-router/api/settings/app", "").expect("request app settings"); + assert_eq!(initial_response.status, 200, "initial backend request should succeed"); + let initial_pid = SIDECAR_CHILD + .lock() + .expect("sidecar child lock") + .as_ref() + .map(|child| child.id()) + .expect("initial child pid"); + assert_eq!(current_backend_addr(), format!("127.0.0.1:{port}")); + + let mut shared_cache = current_settings_cache(); + shared_cache.lan_share_enabled = true; + let restart_required = + persist_runtime_settings(shared_cache).expect("persist shared runtime settings"); + assert!(restart_required, "lan toggle should require sidecar restart"); + restart_sidecar_and_wait_ready().expect("restart sidecar after enabling lan"); + let shared_pid = SIDECAR_CHILD + .lock() + .expect("sidecar child lock") + .as_ref() + .map(|child| child.id()) + .expect("shared child pid"); + assert_ne!(shared_pid, initial_pid, "sidecar pid should change after restart"); + assert_eq!(current_backend_addr(), format!("127.0.0.1:{port}")); + assert_eq!(current_settings_cache().listen_addr(), format!("0.0.0.0:{port}")); + let shared_response = + request_backend("GET", "/ai-router/api/settings/app", "").expect("request shared app settings"); + assert_eq!(shared_response.status, 200, "shared backend request should succeed"); + + let mut local_cache = current_settings_cache(); + local_cache.lan_share_enabled = false; + let restart_required = + persist_runtime_settings(local_cache).expect("persist local runtime settings"); + assert!(restart_required, "lan toggle reset should require sidecar restart"); + restart_sidecar_and_wait_ready().expect("restart sidecar after disabling lan"); + let final_pid = SIDECAR_CHILD + .lock() + .expect("sidecar child lock") + .as_ref() + .map(|child| child.id()) + .expect("final child pid"); + assert_ne!(final_pid, shared_pid, "sidecar pid should change after second restart"); + assert_eq!(current_backend_addr(), format!("127.0.0.1:{port}")); + assert_eq!(current_settings_cache().listen_addr(), format!("127.0.0.1:{port}")); + let final_response = + request_backend("GET", "/ai-router/api/settings/app", "").expect("request local app settings"); + assert_eq!(final_response.status, 200, "final backend request should succeed"); + + shutdown_sidecar_with_reason("smoke-cleanup-end"); + let _ = fs::remove_file(&sidecar_path); + let _ = fs::remove_file(temp_root.join("desktop-settings.json")); + let _ = fs::remove_file(temp_root.join("aigate.sqlite")); + let _ = fs::remove_dir_all(&temp_root); + } + #[test] fn sanitize_main_window_size_clamps_small_dimensions_to_minimum() { let size = sanitize_main_window_size(800, 600).expect("size should be accepted"); diff --git a/frontend/src/features/settings/SettingsPage.test.tsx b/frontend/src/features/settings/SettingsPage.test.tsx index 510cd01..d4fb518 100644 --- a/frontend/src/features/settings/SettingsPage.test.tsx +++ b/frontend/src/features/settings/SettingsPage.test.tsx @@ -21,6 +21,8 @@ const baseSettings = { usage_request_timeout_seconds: 15, proxy_host: "127.0.0.1", proxy_port: 6789, + lan_share_enabled: false, + lan_share_ip_whitelist: "", upstream_proxy_mode: "system", upstream_proxy_url: "", upstream_proxy_username: "", @@ -110,6 +112,10 @@ describe("SettingsPage", () => { expect(screen.getByRole("tab", { name: "数据" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "关于" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "代理" })); + expect(screen.queryByRole("textbox", { name: "代理主机" })).not.toBeInTheDocument(); + expect(await screen.findByRole("switch", { name: "局域网共享" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("switch", { name: "局域网共享" })); + fireEvent.change(screen.getByRole("textbox", { name: "IP 白名单" }), { target: { value: "192.168.1.10\n192.168.1.0/24" } }); expect(await screen.findByRole("switch", { name: "自动故障转移开关" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("radio", { name: "手动指定" })); fireEvent.change(screen.getByRole("textbox", { name: "上游代理地址" }), { target: { value: "http://127.0.0.1:7890" } }); @@ -131,6 +137,8 @@ describe("SettingsPage", () => { body: JSON.stringify({ ...baseSettings, launch_at_login: true, + lan_share_enabled: true, + lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24", upstream_proxy_mode: "manual", upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, @@ -142,6 +150,8 @@ describe("SettingsPage", () => { expect(applyDesktopAppSettings).toHaveBeenCalledWith({ ...baseSettings, launch_at_login: true, + lan_share_enabled: true, + lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24", upstream_proxy_mode: "manual", upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, @@ -150,6 +160,8 @@ describe("SettingsPage", () => { expect(onSettingsChanged).toHaveBeenCalledWith({ ...baseSettings, launch_at_login: true, + lan_share_enabled: true, + lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24", upstream_proxy_mode: "manual", upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, diff --git a/frontend/src/features/settings/SettingsPage.tsx b/frontend/src/features/settings/SettingsPage.tsx index 5fd78ad..900a1f4 100644 --- a/frontend/src/features/settings/SettingsPage.tsx +++ b/frontend/src/features/settings/SettingsPage.tsx @@ -49,6 +49,7 @@ import appLogo from "../../assets/aigate_1024_1024.png"; import { UpdateCard } from "../updates/UpdateCard"; const { Text, Title } = Typography; +const { TextArea } = Input; type SettingsTabKey = "general" | "proxy" | "advanced" | "about"; @@ -183,6 +184,8 @@ export function SettingsPage({ provider_pricing: initialSettings.provider_pricing ?? {}, account_pricing: initialSettings.account_pricing ?? {}, usage_request_timeout_seconds: initialSettings.usage_request_timeout_seconds ?? 15, + lan_share_enabled: initialSettings.lan_share_enabled ?? false, + lan_share_ip_whitelist: initialSettings.lan_share_ip_whitelist ?? "", upstream_proxy_mode: initialSettings.upstream_proxy_mode ?? "system", upstream_proxy_url: initialSettings.upstream_proxy_url ?? "", upstream_proxy_username: initialSettings.upstream_proxy_username ?? "", @@ -530,7 +533,9 @@ export function SettingsPage({ {t("本地代理")} {proxyEnabled ? t("已开启") : t("未开启")} - {t("当前地址")} {draftSettings.proxy_host}:{draftSettings.proxy_port} + {draftSettings.lan_share_enabled + ? `${t("本机地址")} 127.0.0.1:${draftSettings.proxy_port} · ${t("局域网地址")} 0.0.0.0:${draftSettings.proxy_port}` + : `${t("当前地址")} 127.0.0.1:${draftSettings.proxy_port}`}
@@ -551,6 +556,14 @@ export function SettingsPage({ loading={proxySwitchBusy} onChange={(checked) => void handleProxyToggle(checked)} /> + } + title={t("局域网共享")} + description={t("开启后使用当前端口对局域网开放访问;本机 127.0.0.1 / ::1 永远允许访问。")} + label={t("局域网共享")} + checked={draftSettings.lan_share_enabled} + onChange={(checked) => updateDraft({ lan_share_enabled: checked })} + /> } title={t("自动故障转移开关")} @@ -561,15 +574,6 @@ export function SettingsPage({ />
- +