diff --git a/.dockerignore b/.dockerignore index 7f2c142ac..7bc97feca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,3 +28,4 @@ bin/* .claude/* .vscode/* .serena/* +.bmad/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9014b273c..78c9de689 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ GEMINI.md .vscode/* .claude/* .serena/* +.bmad/* .mcp/cache/ # macOS diff --git a/config.example.yaml b/config.example.yaml index 4e72f2b9d..922fcd969 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,9 @@ remote-management: # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false + # GitHub repository for the management control panel. Accepts a repository URL or releases API URL. + panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" + # Authentication directory (supports ~ for home directory) auth-dir: "~/.cli-proxy-api" diff --git a/go.mod b/go.mod index 85d816c90..632ac35a2 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,8 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.7.0 - golang.org/x/crypto v0.43.0 - golang.org/x/net v0.46.0 + golang.org/x/crypto v0.45.0 + golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.30.0 golang.org/x/term v0.36.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -69,9 +69,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 833c45b90..d4a4cb9d6 100644 --- a/go.sum +++ b/go.sum @@ -160,23 +160,23 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= diff --git a/internal/api/server.go b/internal/api/server.go index 591427be3..c43f99716 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -628,7 +628,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { - go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL) + go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) c.AbortWithStatus(http.StatusNotFound) return } @@ -948,7 +948,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { if !cfg.RemoteManagement.DisableControlPanel { staticDir := managementasset.StaticDir(s.configFilePath) - go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL) + go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) } if s.mgmt != nil { s.mgmt.SetConfig(cfg) diff --git a/internal/config/config.go b/internal/config/config.go index 113840c2f..3056fc4b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,8 @@ import ( "gopkg.in/yaml.v3" ) +const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" + // Config represents the application's configuration, loaded from a YAML file. type Config struct { config.SDKConfig `yaml:",inline"` @@ -116,6 +118,9 @@ type RemoteManagement struct { SecretKey string `yaml:"secret-key"` // DisableControlPanel skips serving and syncing the bundled management UI when true. DisableControlPanel bool `yaml:"disable-control-panel"` + // PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset. + // Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint. + PanelGitHubRepository string `yaml:"panel-github-repository"` } // QuotaExceeded defines the behavior when API quota limits are exceeded. @@ -369,6 +374,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient + cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force) if err = yaml.Unmarshal(data, &cfg); err != nil { if optional { @@ -405,6 +411,11 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { _ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed) } + cfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository) + if cfg.RemoteManagement.PanelGitHubRepository == "" { + cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository + } + // Sync request authentication providers with inline API keys for backwards compatibility. syncInlineAccessProvider(&cfg) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 4a9ba0957..420c110f0 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -23,10 +24,10 @@ import ( ) const ( - managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest" - managementAssetName = "management.html" - httpUserAgent = "CLIProxyAPI-management-updater" - updateCheckInterval = 3 * time.Hour + defaultManagementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest" + managementAssetName = "management.html" + httpUserAgent = "CLIProxyAPI-management-updater" + updateCheckInterval = 3 * time.Hour ) // ManagementFileName exposes the control panel asset filename. @@ -97,7 +98,7 @@ func runAutoUpdater(ctx context.Context) { configPath, _ := schedulerConfigPath.Load().(string) staticDir := StaticDir(configPath) - EnsureLatestManagementHTML(ctx, staticDir, cfg.ProxyURL) + EnsureLatestManagementHTML(ctx, staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) } runOnce() @@ -181,7 +182,7 @@ func FilePath(configFilePath string) string { // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. // The function is designed to run in a background goroutine and will never panic. // It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes. -func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) { +func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { if ctx == nil { ctx = context.Background() } @@ -214,6 +215,7 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL return } + releaseURL := resolveReleaseURL(panelRepository) client := newHTTPClient(proxyURL) localPath := filepath.Join(staticDir, managementAssetName) @@ -225,7 +227,7 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL localHash = "" } - asset, remoteHash, err := fetchLatestAsset(ctx, client) + asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL) if err != nil { log.WithError(err).Warn("failed to fetch latest management release information") return @@ -254,8 +256,44 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL log.Infof("management asset updated successfully (hash=%s)", downloadedHash) } -func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil) +func resolveReleaseURL(repo string) string { + repo = strings.TrimSpace(repo) + if repo == "" { + return defaultManagementReleaseURL + } + + parsed, err := url.Parse(repo) + if err != nil || parsed.Host == "" { + return defaultManagementReleaseURL + } + + host := strings.ToLower(parsed.Host) + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + + if host == "api.github.com" { + if !strings.HasSuffix(strings.ToLower(parsed.Path), "/releases/latest") { + parsed.Path = parsed.Path + "/releases/latest" + } + return parsed.String() + } + + if host == "github.com" { + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" { + repoName := strings.TrimSuffix(parts[1], ".git") + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", parts[0], repoName) + } + } + + return defaultManagementReleaseURL +} + +func fetchLatestAsset(ctx context.Context, client *http.Client, releaseURL string) (*releaseAsset, string, error) { + if strings.TrimSpace(releaseURL) == "" { + releaseURL = defaultManagementReleaseURL + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil) if err != nil { return nil, "", fmt.Errorf("create release request: %w", err) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index a3e2859cb..7c2976eea 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -2091,6 +2091,11 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel { changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel)) } + oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository) + newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository) + if oldPanelRepo != newPanelRepo { + changes = append(changes, fmt.Sprintf("remote-management.panel-github-repository: %s -> %s", oldPanelRepo, newPanelRepo)) + } if oldCfg.RemoteManagement.SecretKey != newCfg.RemoteManagement.SecretKey { switch { case oldCfg.RemoteManagement.SecretKey == "" && newCfg.RemoteManagement.SecretKey != "":