diff --git a/.dockerignore b/.dockerignore index a794020d..7f2c142a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,9 +17,6 @@ MANAGEMENT_API.md MANAGEMENT_API_CN.md LICENSE -# Example configuration -config.example.yaml - # Runtime data folders (should be mounted as volumes) auths/* logs/* diff --git a/Dockerfile b/Dockerfile index 8cedb065..8623dc5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,8 @@ RUN mkdir /CLIProxyAPI COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI +COPY config.example.yaml /CLIProxyAPI/config.example.yaml + WORKDIR /CLIProxyAPI EXPOSE 8317 diff --git a/cmd/server/main.go b/cmd/server/main.go index 2abc5a15..7ec1f6ae 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,15 +4,21 @@ package main import ( + "context" + "errors" "flag" "fmt" + "io/fs" "os" "path/filepath" + "strings" configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" @@ -93,8 +99,45 @@ func main() { // Core application variables. var err error var cfg *config.Config - var wd string var isCloudDeploy bool + var ( + gitStoreLocalPath string + useGitStore bool + gitStoreRemoteURL string + gitStoreUser string + gitStorePassword string + gitStoreInst *store.GitTokenStore + gitStoreRoot string + ) + + wd, err := os.Getwd() + if err != nil { + log.Fatalf("failed to get working directory: %v", err) + } + + lookupEnv := func(keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed, true + } + } + } + return "", false + } + if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok { + useGitStore = true + gitStoreRemoteURL = value + } + if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok { + gitStoreUser = value + } + if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok { + gitStorePassword = value + } + if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok { + gitStoreLocalPath = value + } // Check for cloud deploy mode only on first execution // Read env var name in uppercase: DEPLOY @@ -104,10 +147,44 @@ func main() { } // Determine and load the configuration file. - // If a config path is provided via flags, it is used directly. - // Otherwise, it defaults to "config.yaml" in the current working directory. + // If gitstore is configured, load from the cloned repository; otherwise use the provided path or default. var configFilePath string - if configPath != "" { + if useGitStore { + if gitStoreLocalPath == "" { + gitStoreLocalPath = wd + } + gitStoreRoot = filepath.Join(gitStoreLocalPath, "remote") + authDir := filepath.Join(gitStoreRoot, "auths") + gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword) + gitStoreInst.SetBaseDir(authDir) + if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil { + log.Fatalf("failed to prepare git token store: %v", errRepo) + } + configFilePath = gitStoreInst.ConfigPath() + if configFilePath == "" { + configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml") + } + if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) { + examplePath := filepath.Join(wd, "config.example.yaml") + if _, errExample := os.Stat(examplePath); errExample != nil { + log.Fatalf("failed to find template config file: %v", errExample) + } + if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil { + log.Fatalf("failed to bootstrap git-backed config: %v", errCopy) + } + if errCommit := gitStoreInst.CommitConfig(context.Background()); errCommit != nil { + log.Fatalf("failed to commit initial git-backed config: %v", errCommit) + } + log.Infof("git-backed config initialized from template: %s", configFilePath) + } else if statErr != nil { + log.Fatalf("failed to inspect git-backed config: %v", statErr) + } + cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) + if err == nil { + cfg.AuthDir = gitStoreInst.AuthDir() + log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot) + } + } else if configPath != "" { configFilePath = configPath cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy) } else { @@ -121,6 +198,9 @@ func main() { if err != nil { log.Fatalf("failed to load config: %v", err) } + if cfg == nil { + cfg = &config.Config{} + } // In cloud deploy mode, check if we have a valid configuration var configFileExists bool @@ -165,7 +245,11 @@ func main() { } // Register the shared token store once so all components use the same persistence backend. - sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore()) + if useGitStore { + sdkAuth.RegisterTokenStore(gitStoreInst) + } else { + sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore()) + } // Register built-in access providers before constructing services. configaccess.Register() diff --git a/docker-compose.yml b/docker-compose.yml index 63f3476a..244d13ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,5 +22,4 @@ services: - ./config.yaml:/CLIProxyAPI/config.yaml - ./auths:/root/.cli-proxy-api - ./logs:/CLIProxyAPI/logs - - ./conv:/CLIProxyAPI/conv restart: unless-stopped diff --git a/go.mod b/go.mod index fa31a7d5..f8970674 100644 --- a/go.mod +++ b/go.mod @@ -1,49 +1,60 @@ module github.com/router-for-me/CLIProxyAPI/v6 -go 1.24 +go 1.24.0 require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.10.1 + github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 github.com/google/uuid v1.6.0 + github.com/klauspost/compress v1.17.3 github.com/sirupsen/logrus v1.9.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - go.etcd.io/bbolt v1.3.8 - golang.org/x/crypto v0.36.0 - golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.46.0 golang.org/x/oauth2 v0.30.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.3 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect 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/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 5c8f0b1d..d4718d1c 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,32 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -19,6 +35,16 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA= +github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM= +github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= +github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= +github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0= +github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -29,6 +55,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -36,12 +64,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA= github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -53,8 +89,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= @@ -64,13 +106,15 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -84,32 +128,35 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= -go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 h1:wneCP+2d9NUmndnyTmY7VwUNYiP26xiN/AtdcojQ1lI= -golang.org/x/net v0.37.1-0.20250305215238-2914f4677317/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +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/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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/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= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 2e81c349..9497e39b 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -25,28 +26,33 @@ type attemptInfo struct { // Handler aggregates config reference, persistence path and helpers. type Handler struct { - cfg *config.Config - configFilePath string - mu sync.Mutex - - attemptsMu sync.Mutex - failedAttempts map[string]*attemptInfo // keyed by client IP - authManager *coreauth.Manager - usageStats *usage.RequestStatistics - tokenStore coreauth.Store - - localPassword string + cfg *config.Config + configFilePath string + mu sync.Mutex + attemptsMu sync.Mutex + failedAttempts map[string]*attemptInfo // keyed by client IP + authManager *coreauth.Manager + usageStats *usage.RequestStatistics + tokenStore coreauth.Store + localPassword string + allowRemoteOverride bool + envSecret string } // NewHandler creates a new management handler instance. func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler { + envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD") + envSecret = strings.TrimSpace(envSecret) + return &Handler{ - cfg: cfg, - configFilePath: configFilePath, - failedAttempts: make(map[string]*attemptInfo), - authManager: manager, - usageStats: usage.GetRequestStatistics(), - tokenStore: sdkAuth.GetTokenStore(), + cfg: cfg, + configFilePath: configFilePath, + failedAttempts: make(map[string]*attemptInfo), + authManager: manager, + usageStats: usage.GetRequestStatistics(), + tokenStore: sdkAuth.GetTokenStore(), + allowRemoteOverride: envSecret != "", + envSecret: envSecret, } } @@ -72,6 +78,19 @@ func (h *Handler) Middleware() gin.HandlerFunc { return func(c *gin.Context) { clientIP := c.ClientIP() localClient := clientIP == "127.0.0.1" || clientIP == "::1" + cfg := h.cfg + var ( + allowRemote bool + secretHash string + ) + if cfg != nil { + allowRemote = cfg.RemoteManagement.AllowRemote + secretHash = cfg.RemoteManagement.SecretKey + } + if h.allowRemoteOverride { + allowRemote = true + } + envSecret := h.envSecret fail := func() {} if !localClient { @@ -92,7 +111,7 @@ func (h *Handler) Middleware() gin.HandlerFunc { } h.attemptsMu.Unlock() - if !h.cfg.RemoteManagement.AllowRemote { + if !allowRemote { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"}) return } @@ -112,8 +131,7 @@ func (h *Handler) Middleware() gin.HandlerFunc { h.attemptsMu.Unlock() } } - secret := h.cfg.RemoteManagement.SecretKey - if secret == "" { + if secretHash == "" && envSecret == "" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"}) return } @@ -149,7 +167,20 @@ func (h *Handler) Middleware() gin.HandlerFunc { } } - if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil { + if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 { + if !localClient { + h.attemptsMu.Lock() + if ai := h.failedAttempts[clientIP]; ai != nil { + ai.count = 0 + ai.blockedUntil = time.Time{} + } + h.attemptsMu.Unlock() + } + c.Next() + return + } + + if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil { if !localClient { fail() } diff --git a/internal/api/server.go b/internal/api/server.go index 78a35162..560fc0b7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -126,6 +126,9 @@ type Server struct { // configFilePath is the absolute path to the YAML config file for persistence. configFilePath string + // currentPath is the absolute path to the current working directory. + currentPath string + // management handler mgmt *managementHandlers.Handler @@ -134,6 +137,9 @@ type Server struct { // managementRoutesEnabled controls whether management endpoints serve real handlers. managementRoutesEnabled atomic.Bool + // envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured. + envManagementSecret bool + localPassword string keepAliveEnabled bool @@ -193,16 +199,26 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk } engine.Use(corsMiddleware()) + wd, err := os.Getwd() + if err != nil { + wd = configFilePath + } + + envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD") + envAdminPassword = strings.TrimSpace(envAdminPassword) + envManagementSecret := envAdminPasswordSet && envAdminPassword != "" // Create server instance s := &Server{ - engine: engine, - handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager), - cfg: cfg, - accessManager: accessManager, - requestLogger: requestLogger, - loggerToggle: toggle, - configFilePath: configFilePath, + engine: engine, + handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager), + cfg: cfg, + accessManager: accessManager, + requestLogger: requestLogger, + loggerToggle: toggle, + configFilePath: configFilePath, + currentPath: wd, + envManagementSecret: envManagementSecret, } s.applyAccessConfig(nil, cfg) // Initialize management handler @@ -218,9 +234,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk optionState.routerConfigurator(engine, s.handlers, cfg) } - // Register management routes only when a secret is present at startup. - s.managementRoutesEnabled.Store(cfg.RemoteManagement.SecretKey != "") - if cfg.RemoteManagement.SecretKey != "" { + // Register management routes when configuration or environment secrets are available. + hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret + s.managementRoutesEnabled.Store(hasManagementSecret) + if hasManagementSecret { s.registerManagementRoutes() } @@ -272,7 +289,6 @@ func (s *Server) setupRoutes() { s.engine.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "CLI Proxy API Server", - "version": "1.0.0", "endpoints": []string{ "POST /v1/chat/completions", "POST /v1/completions", @@ -441,8 +457,8 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) return } - - filePath := managementasset.FilePath(s.configFilePath) + println(s.currentPath) + filePath := managementasset.FilePath(s.currentPath) if strings.TrimSpace(filePath) == "" { c.AbortWithStatus(http.StatusNotFound) return @@ -450,7 +466,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.currentPath), cfg.ProxyURL) c.AbortWithStatus(http.StatusNotFound) return } @@ -691,22 +707,31 @@ func (s *Server) UpdateClients(cfg *config.Config) { prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == "" } newSecretEmpty := cfg.RemoteManagement.SecretKey == "" - switch { - case prevSecretEmpty && !newSecretEmpty: + if s.envManagementSecret { s.registerManagementRoutes() if s.managementRoutesEnabled.CompareAndSwap(false, true) { - log.Info("management routes enabled after secret key update") + log.Info("management routes enabled via MANAGEMENT_PASSWORD") } else { s.managementRoutesEnabled.Store(true) } - case !prevSecretEmpty && newSecretEmpty: - if s.managementRoutesEnabled.CompareAndSwap(true, false) { - log.Info("management routes disabled after secret key removal") - } else { - s.managementRoutesEnabled.Store(false) + } else { + switch { + case prevSecretEmpty && !newSecretEmpty: + s.registerManagementRoutes() + if s.managementRoutesEnabled.CompareAndSwap(false, true) { + log.Info("management routes enabled after secret key update") + } else { + s.managementRoutesEnabled.Store(true) + } + case !prevSecretEmpty && newSecretEmpty: + if s.managementRoutesEnabled.CompareAndSwap(true, false) { + log.Info("management routes disabled after secret key removal") + } else { + s.managementRoutesEnabled.Store(false) + } + default: + s.managementRoutesEnabled.Store(!newSecretEmpty) } - default: - s.managementRoutesEnabled.Store(!newSecretEmpty) } s.applyAccessConfig(oldCfg, cfg) @@ -714,7 +739,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.handlers.UpdateClients(&cfg.SDKConfig) if !cfg.RemoteManagement.DisableControlPanel { - staticDir := managementasset.StaticDir(s.configFilePath) + staticDir := managementasset.StaticDir(s.currentPath) go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL) } if s.mgmt != nil { diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 7478564b..a1b8370a 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -60,7 +60,15 @@ func StaticDir(configFilePath string) string { if configFilePath == "" { return "" } + base := filepath.Dir(configFilePath) + fileInfo, err := os.Stat(configFilePath) + if err == nil { + if fileInfo.IsDir() { + base = configFilePath + } + } + return filepath.Join(base, "static") } diff --git a/internal/misc/copy-example-config.go b/internal/misc/copy-example-config.go new file mode 100644 index 00000000..61a25fe4 --- /dev/null +++ b/internal/misc/copy-example-config.go @@ -0,0 +1,40 @@ +package misc + +import ( + "io" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" +) + +func CopyConfigTemplate(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { + if errClose := in.Close(); errClose != nil { + log.WithError(errClose).Warn("failed to close source config file") + } + }() + + if err = os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + if errClose := out.Close(); errClose != nil { + log.WithError(errClose).Warn("failed to close destination config file") + } + }() + + if _, err = io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go new file mode 100644 index 00000000..95d2c1f8 --- /dev/null +++ b/internal/store/gitstore.go @@ -0,0 +1,749 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/plumbing/transport" + "github.com/go-git/go-git/v6/plumbing/transport/http" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +// GitTokenStore persists token records and auth metadata using git as the backing storage. +type GitTokenStore struct { + mu sync.Mutex + dirLock sync.RWMutex + baseDir string + repoDir string + configDir string + remote string + username string + password string +} + +// NewGitTokenStore creates a token store that saves credentials to disk through the +// TokenStorage implementation embedded in the token record. +func NewGitTokenStore(remote, username, password string) *GitTokenStore { + return &GitTokenStore{ + remote: remote, + username: username, + password: password, + } +} + +// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided. +func (s *GitTokenStore) SetBaseDir(dir string) { + clean := strings.TrimSpace(dir) + if clean == "" { + s.dirLock.Lock() + s.baseDir = "" + s.repoDir = "" + s.configDir = "" + s.dirLock.Unlock() + return + } + if abs, err := filepath.Abs(clean); err == nil { + clean = abs + } + repoDir := filepath.Dir(clean) + if repoDir == "" || repoDir == "." { + repoDir = clean + } + configDir := filepath.Join(repoDir, "config") + s.dirLock.Lock() + s.baseDir = clean + s.repoDir = repoDir + s.configDir = configDir + s.dirLock.Unlock() +} + +// AuthDir returns the directory used for auth persistence. +func (s *GitTokenStore) AuthDir() string { + return s.baseDirSnapshot() +} + +// ConfigPath returns the managed config file path. +func (s *GitTokenStore) ConfigPath() string { + s.dirLock.RLock() + defer s.dirLock.RUnlock() + if s.configDir == "" { + return "" + } + return filepath.Join(s.configDir, "config.yaml") +} + +// EnsureRepository prepares the local git working tree by cloning or opening the repository. +func (s *GitTokenStore) EnsureRepository() error { + s.dirLock.Lock() + if s.remote == "" { + s.dirLock.Unlock() + return fmt.Errorf("git token store: remote not configured") + } + if s.baseDir == "" { + s.dirLock.Unlock() + return fmt.Errorf("git token store: base directory not configured") + } + repoDir := s.repoDir + if repoDir == "" { + repoDir = filepath.Dir(s.baseDir) + if repoDir == "" || repoDir == "." { + repoDir = s.baseDir + } + s.repoDir = repoDir + } + if s.configDir == "" { + s.configDir = filepath.Join(repoDir, "config") + } + authDir := filepath.Join(repoDir, "auths") + configDir := filepath.Join(repoDir, "config") + gitDir := filepath.Join(repoDir, ".git") + authMethod := s.gitAuth() + var initPaths []string + if _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) { + if errMk := os.MkdirAll(repoDir, 0o700); errMk != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create repo dir: %w", errMk) + } + if _, errClone := git.PlainClone(repoDir, &git.CloneOptions{Auth: authMethod, URL: s.remote}); errClone != nil { + if errors.Is(errClone, transport.ErrEmptyRemoteRepository) { + _ = os.RemoveAll(gitDir) + repo, errInit := git.PlainInit(repoDir, false) + if errInit != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: init empty repo: %w", errInit) + } + if _, errRemote := repo.Remote("origin"); errRemote != nil { + if _, errCreate := repo.CreateRemote(&config.RemoteConfig{ + Name: "origin", + URLs: []string{s.remote}, + }); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: configure remote: %w", errCreate) + } + } + if err := os.MkdirAll(authDir, 0o700); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create auth dir: %w", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create config dir: %w", err) + } + if err := ensureEmptyFile(filepath.Join(authDir, ".gitkeep")); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create auth placeholder: %w", err) + } + if err := ensureEmptyFile(filepath.Join(configDir, ".gitkeep")); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create config placeholder: %w", err) + } + initPaths = []string{ + filepath.Join("auths", ".gitkeep"), + filepath.Join("config", ".gitkeep"), + } + } else { + s.dirLock.Unlock() + return fmt.Errorf("git token store: clone remote: %w", errClone) + } + } + } else if err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: stat repo: %w", err) + } else { + repo, errOpen := git.PlainOpen(repoDir) + if errOpen != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: open repo: %w", errOpen) + } + worktree, errWorktree := repo.Worktree() + if errWorktree != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: worktree: %w", errWorktree) + } + if errPull := worktree.Pull(&git.PullOptions{Auth: authMethod, RemoteName: "origin"}); errPull != nil { + switch { + case errors.Is(errPull, git.NoErrAlreadyUpToDate), + errors.Is(errPull, git.ErrUnstagedChanges), + errors.Is(errPull, git.ErrNonFastForwardUpdate): + // Ignore clean syncs, local edits, and remote divergence—local changes win. + case errors.Is(errPull, transport.ErrAuthenticationRequired), + errors.Is(errPull, plumbing.ErrReferenceNotFound), + errors.Is(errPull, transport.ErrEmptyRemoteRepository): + // Ignore authentication prompts and empty remote references on initial sync. + default: + s.dirLock.Unlock() + return fmt.Errorf("git token store: pull: %w", errPull) + } + } + } + if err := os.MkdirAll(s.baseDir, 0o700); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create auth dir: %w", err) + } + if err := os.MkdirAll(s.configDir, 0o700); err != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: create config dir: %w", err) + } + s.dirLock.Unlock() + if len(initPaths) > 0 { + s.mu.Lock() + err := s.commitAndPushLocked("Initialize git token store", initPaths...) + s.mu.Unlock() + if err != nil { + return err + } + } + return nil +} + +// Save persists token storage and metadata to the resolved auth file path. +func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string, error) { + if auth == nil { + return "", fmt.Errorf("auth filestore: auth is nil") + } + + path, err := s.resolveAuthPath(auth) + if err != nil { + return "", err + } + if path == "" { + return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID) + } + + if auth.Disabled { + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + return "", nil + } + } + + if err = s.EnsureRepository(); err != nil { + return "", err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("auth filestore: create dir failed: %w", err) + } + + switch { + case auth.Storage != nil: + if err = auth.Storage.SaveTokenToFile(path); err != nil { + return "", err + } + case auth.Metadata != nil: + raw, errMarshal := json.Marshal(auth.Metadata) + if errMarshal != nil { + return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) + } + if existing, errRead := os.ReadFile(path); errRead == nil { + if jsonEqual(existing, raw) { + return path, nil + } + } else if !os.IsNotExist(errRead) { + return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) + } + tmp := path + ".tmp" + if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil { + return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite) + } + if errRename := os.Rename(tmp, path); errRename != nil { + return "", fmt.Errorf("auth filestore: rename failed: %w", errRename) + } + default: + return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) + } + + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["path"] = path + + if strings.TrimSpace(auth.FileName) == "" { + auth.FileName = auth.ID + } + + relPath, errRel := s.relativeToRepo(path) + if errRel != nil { + return "", errRel + } + messageID := auth.ID + if strings.TrimSpace(messageID) == "" { + messageID = filepath.Base(path) + } + if errCommit := s.commitAndPushLocked(fmt.Sprintf("Update auth %s", strings.TrimSpace(messageID)), relPath); errCommit != nil { + return "", errCommit + } + + return path, nil +} + +// List enumerates all auth JSON files under the configured directory. +func (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) { + if err := s.EnsureRepository(); err != nil { + return nil, err + } + dir := s.baseDirSnapshot() + if dir == "" { + return nil, fmt.Errorf("auth filestore: directory not configured") + } + entries := make([]*cliproxyauth.Auth, 0) + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") { + return nil + } + auth, err := s.readAuthFile(path, dir) + if err != nil { + return nil + } + if auth != nil { + entries = append(entries, auth) + } + return nil + }) + if err != nil { + return nil, err + } + return entries, nil +} + +// Delete removes the auth file. +func (s *GitTokenStore) Delete(_ context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("auth filestore: id is empty") + } + path, err := s.resolveDeletePath(id) + if err != nil { + return err + } + if err = s.EnsureRepository(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err = os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("auth filestore: delete failed: %w", err) + } + if err == nil { + rel, errRel := s.relativeToRepo(path) + if errRel != nil { + return errRel + } + messageID := id + if errCommit := s.commitAndPushLocked(fmt.Sprintf("Delete auth %s", messageID), rel); errCommit != nil { + return errCommit + } + } + return nil +} + +// CommitPaths commits and pushes the provided paths to the remote repository. +// It no-ops when the store is not fully configured or when there are no paths. +func (s *GitTokenStore) CommitPaths(_ context.Context, message string, paths ...string) error { + if len(paths) == 0 { + return nil + } + if err := s.EnsureRepository(); err != nil { + return err + } + + filtered := make([]string, 0, len(paths)) + for _, p := range paths { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + continue + } + rel, err := s.relativeToRepo(trimmed) + if err != nil { + return err + } + filtered = append(filtered, rel) + } + if len(filtered) == 0 { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + if strings.TrimSpace(message) == "" { + message = "Sync watcher updates" + } + return s.commitAndPushLocked(message, filtered...) +} + +func (s *GitTokenStore) resolveDeletePath(id string) (string, error) { + if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) { + return id, nil + } + dir := s.baseDirSnapshot() + if dir == "" { + return "", fmt.Errorf("auth filestore: directory not configured") + } + return filepath.Join(dir, id), nil +} + +func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + if len(data) == 0 { + return nil, nil + } + metadata := make(map[string]any) + if err = json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("unmarshal auth json: %w", err) + } + provider, _ := metadata["type"].(string) + if provider == "" { + provider = "unknown" + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat file: %w", err) + } + id := s.idFor(path, baseDir) + auth := &cliproxyauth.Auth{ + ID: id, + Provider: provider, + FileName: id, + Label: s.labelFor(metadata), + Status: cliproxyauth.StatusActive, + Attributes: map[string]string{"path": path}, + Metadata: metadata, + CreatedAt: info.ModTime(), + UpdatedAt: info.ModTime(), + LastRefreshedAt: time.Time{}, + NextRefreshAfter: time.Time{}, + } + if email, ok := metadata["email"].(string); ok && email != "" { + auth.Attributes["email"] = email + } + return auth, nil +} + +func (s *GitTokenStore) idFor(path, baseDir string) string { + if baseDir == "" { + return path + } + rel, err := filepath.Rel(baseDir, path) + if err != nil { + return path + } + return rel +} + +func (s *GitTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) { + if auth == nil { + return "", fmt.Errorf("auth filestore: auth is nil") + } + if auth.Attributes != nil { + if p := strings.TrimSpace(auth.Attributes["path"]); p != "" { + return p, nil + } + } + if fileName := strings.TrimSpace(auth.FileName); fileName != "" { + if filepath.IsAbs(fileName) { + return fileName, nil + } + if dir := s.baseDirSnapshot(); dir != "" { + return filepath.Join(dir, fileName), nil + } + return fileName, nil + } + if auth.ID == "" { + return "", fmt.Errorf("auth filestore: missing id") + } + if filepath.IsAbs(auth.ID) { + return auth.ID, nil + } + dir := s.baseDirSnapshot() + if dir == "" { + return "", fmt.Errorf("auth filestore: directory not configured") + } + return filepath.Join(dir, auth.ID), nil +} + +func (s *GitTokenStore) labelFor(metadata map[string]any) string { + if metadata == nil { + return "" + } + if v, ok := metadata["label"].(string); ok && v != "" { + return v + } + if v, ok := metadata["email"].(string); ok && v != "" { + return v + } + if project, ok := metadata["project_id"].(string); ok && project != "" { + return project + } + return "" +} + +func (s *GitTokenStore) baseDirSnapshot() string { + s.dirLock.RLock() + defer s.dirLock.RUnlock() + return s.baseDir +} + +func (s *GitTokenStore) repoDirSnapshot() string { + s.dirLock.RLock() + defer s.dirLock.RUnlock() + return s.repoDir +} + +func (s *GitTokenStore) gitAuth() transport.AuthMethod { + if s.username == "" && s.password == "" { + return nil + } + user := s.username + if user == "" { + user = "git" + } + return &http.BasicAuth{Username: user, Password: s.password} +} + +func (s *GitTokenStore) relativeToRepo(path string) (string, error) { + repoDir := s.repoDirSnapshot() + if repoDir == "" { + return "", fmt.Errorf("git token store: repository path not configured") + } + absRepo := repoDir + if abs, err := filepath.Abs(repoDir); err == nil { + absRepo = abs + } + cleanPath := path + if abs, err := filepath.Abs(path); err == nil { + cleanPath = abs + } + rel, err := filepath.Rel(absRepo, cleanPath) + if err != nil { + return "", fmt.Errorf("git token store: relative path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("git token store: path outside repository") + } + return rel, nil +} + +func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error { + repoDir := s.repoDirSnapshot() + if repoDir == "" { + return fmt.Errorf("git token store: repository path not configured") + } + repo, err := git.PlainOpen(repoDir) + if err != nil { + return fmt.Errorf("git token store: open repo: %w", err) + } + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("git token store: worktree: %w", err) + } + added := false + for _, rel := range relPaths { + if strings.TrimSpace(rel) == "" { + continue + } + if _, err = worktree.Add(rel); err != nil { + if errors.Is(err, os.ErrNotExist) { + if _, errRemove := worktree.Remove(rel); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { + return fmt.Errorf("git token store: remove %s: %w", rel, errRemove) + } + } else { + return fmt.Errorf("git token store: add %s: %w", rel, err) + } + } + added = true + } + if !added { + return nil + } + status, err := worktree.Status() + if err != nil { + return fmt.Errorf("git token store: status: %w", err) + } + if status.IsClean() { + return nil + } + if strings.TrimSpace(message) == "" { + message = "Update auth store" + } + signature := &object.Signature{ + Name: "CLIProxyAPI", + Email: "cliproxy@local", + When: time.Now(), + } + commitHash, err := worktree.Commit(message, &git.CommitOptions{ + Author: signature, + }) + if err != nil { + if errors.Is(err, git.ErrEmptyCommit) { + return nil + } + return fmt.Errorf("git token store: commit: %w", err) + } + headRef, errHead := repo.Head() + if errHead != nil { + if !errors.Is(errHead, plumbing.ErrReferenceNotFound) { + return fmt.Errorf("git token store: get head: %w", errHead) + } + } else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil { + return errRewrite + } + if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil { + if errors.Is(err, git.NoErrAlreadyUpToDate) { + return nil + } + return fmt.Errorf("git token store: push: %w", err) + } + return nil +} + +// rewriteHeadAsSingleCommit rewrites the current branch tip to a single-parentless commit and leaves history squashed. +func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch plumbing.ReferenceName, commitHash plumbing.Hash, message string, signature *object.Signature) error { + commitObj, err := repo.CommitObject(commitHash) + if err != nil { + return fmt.Errorf("git token store: inspect head commit: %w", err) + } + squashed := &object.Commit{ + Author: *signature, + Committer: *signature, + Message: message, + TreeHash: commitObj.TreeHash, + ParentHashes: nil, + Encoding: commitObj.Encoding, + ExtraHeaders: commitObj.ExtraHeaders, + } + mem := &plumbing.MemoryObject{} + mem.SetType(plumbing.CommitObject) + if err := squashed.Encode(mem); err != nil { + return fmt.Errorf("git token store: encode squashed commit: %w", err) + } + newHash, err := repo.Storer.SetEncodedObject(mem) + if err != nil { + return fmt.Errorf("git token store: write squashed commit: %w", err) + } + if err := repo.Storer.SetReference(plumbing.NewHashReference(branch, newHash)); err != nil { + return fmt.Errorf("git token store: update branch reference: %w", err) + } + return nil +} + +// CommitConfig commits and pushes configuration changes to git. +func (s *GitTokenStore) CommitConfig(_ context.Context) error { + if err := s.EnsureRepository(); err != nil { + return err + } + configPath := s.ConfigPath() + if configPath == "" { + return fmt.Errorf("git token store: config path not configured") + } + if _, err := os.Stat(configPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("git token store: stat config: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + rel, err := s.relativeToRepo(configPath) + if err != nil { + return err + } + return s.commitAndPushLocked("Update config", rel) +} + +func ensureEmptyFile(path string) error { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return os.WriteFile(path, []byte{}, 0o600) + } + return err + } + return nil +} + +func jsonEqual(a, b []byte) bool { + var objA any + var objB any + if err := json.Unmarshal(a, &objA); err != nil { + return false + } + if err := json.Unmarshal(b, &objB); err != nil { + return false + } + return deepEqualJSON(objA, objB) +} + +func deepEqualJSON(a, b any) bool { + switch valA := a.(type) { + case map[string]any: + valB, ok := b.(map[string]any) + if !ok || len(valA) != len(valB) { + return false + } + for key, subA := range valA { + subB, ok1 := valB[key] + if !ok1 || !deepEqualJSON(subA, subB) { + return false + } + } + return true + case []any: + sliceB, ok := b.([]any) + if !ok || len(valA) != len(sliceB) { + return false + } + for i := range valA { + if !deepEqualJSON(valA[i], sliceB[i]) { + return false + } + } + return true + case float64: + valB, ok := b.(float64) + if !ok { + return false + } + return valA == valB + case string: + valB, ok := b.(string) + if !ok { + return false + } + return valA == valB + case bool: + valB, ok := b.(bool) + if !ok { + return false + } + return valA == valB + case nil: + return b == nil + default: + return false + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index efa7818e..096c7ad5 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -29,11 +29,18 @@ import ( // "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" // "github.com/tidwall/gjson" ) +// gitCommitter captures the subset of git-backed token store capabilities used by the watcher. +type gitCommitter interface { + CommitConfig(ctx context.Context) error + CommitPaths(ctx context.Context, message string, paths ...string) error +} + // Watcher manages file watching for configuration and authentication files type Watcher struct { configPath string @@ -51,6 +58,7 @@ type Watcher struct { pendingUpdates map[string]AuthUpdate pendingOrder []string dispatchCancel context.CancelFunc + gitCommitter gitCommitter } type stableIDGenerator struct { @@ -114,7 +122,6 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config)) if errNewWatcher != nil { return nil, errNewWatcher } - w := &Watcher{ configPath: configPath, authDir: authDir, @@ -123,6 +130,12 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config)) lastAuthHashes: make(map[string]string), } w.dispatchCond = sync.NewCond(&w.dispatchMu) + if store := sdkAuth.GetTokenStore(); store != nil { + if committer, ok := store.(gitCommitter); ok { + w.gitCommitter = committer + log.Debug("gitstore mode detected; watcher will commit changes to remote repository") + } + } return w, nil } @@ -336,6 +349,41 @@ func (w *Watcher) stopDispatch() { w.clientsMutex.Unlock() } +func (w *Watcher) commitConfigAsync() { + if w == nil || w.gitCommitter == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := w.gitCommitter.CommitConfig(ctx); err != nil { + log.Errorf("failed to commit config change: %v", err) + } + }() +} + +func (w *Watcher) commitAuthAsync(message string, paths ...string) { + if w == nil || w.gitCommitter == nil { + return + } + filtered := make([]string, 0, len(paths)) + for _, p := range paths { + if trimmed := strings.TrimSpace(p); trimmed != "" { + filtered = append(filtered, trimmed) + } + } + if len(filtered) == 0 { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := w.gitCommitter.CommitPaths(ctx, message, filtered...); err != nil { + log.Errorf("failed to commit auth changes: %v", err) + } + }() +} + func authEqual(a, b *coreauth.Auth) bool { return reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b)) } @@ -440,6 +488,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { w.clientsMutex.Lock() w.lastConfigHash = finalHash w.clientsMutex.Unlock() + w.commitConfigAsync() } return } @@ -691,6 +740,7 @@ func (w *Watcher) addOrUpdateClient(path string) { log.Debugf("triggering server update callback after add/update") w.reloadCallback(cfg) } + w.commitAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path) } // removeClient handles the removal of a single client. @@ -708,6 +758,7 @@ func (w *Watcher) removeClient(path string) { log.Debugf("triggering server update callback after removal") w.reloadCallback(cfg) } + w.commitAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path) } // SnapshotCombinedClients returns a snapshot of current combined clients.