luispater commited on
Commit
f6e2efe
·
unverified ·
1 Parent(s): 6d04d0f

feat(store): introduce `GitTokenStore` for token persistence via Git backend

Browse files

- Added `GitTokenStore` to handle token storage and metadata using Git as a backing storage.
- Implemented methods for initialization, save, retrieval, listing, and deletion of auth files.
- Updated `go.mod` and `go.sum` to include new dependencies for Git integration.
- Integrated support for Git-backed configuration via `GitTokenStore`.
- Updated server logic to clone, initialize, and manage configurations from Git repositories.
- Added helper functions for verifying and synchronizing configuration files.
- Improved error handling and contextual logging for Git operations.
- Modified Dockerfile to include `config.example.yaml` for initial setup.
- Added `gitCommitter` interface to handle Git-based commit and push operations.
- Configured `Watcher` to detect and leverage Git-backed token stores.
- Implemented `commitConfigAsync` and `commitAuthAsync` methods for asynchronous change synchronization.
- Enhanced `GitTokenStore` with `CommitPaths` method to support selective file commits.

.dockerignore CHANGED
@@ -17,9 +17,6 @@ MANAGEMENT_API.md
17
  MANAGEMENT_API_CN.md
18
  LICENSE
19
 
20
- # Example configuration
21
- config.example.yaml
22
-
23
  # Runtime data folders (should be mounted as volumes)
24
  auths/*
25
  logs/*
 
17
  MANAGEMENT_API_CN.md
18
  LICENSE
19
 
 
 
 
20
  # Runtime data folders (should be mounted as volumes)
21
  auths/*
22
  logs/*
Dockerfile CHANGED
@@ -22,6 +22,8 @@ RUN mkdir /CLIProxyAPI
22
 
23
  COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
24
 
 
 
25
  WORKDIR /CLIProxyAPI
26
 
27
  EXPOSE 8317
 
22
 
23
  COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
24
 
25
+ COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26
+
27
  WORKDIR /CLIProxyAPI
28
 
29
  EXPOSE 8317
cmd/server/main.go CHANGED
@@ -4,15 +4,21 @@
4
  package main
5
 
6
  import (
 
 
7
  "flag"
8
  "fmt"
 
9
  "os"
10
  "path/filepath"
 
11
 
12
  configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
13
  "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
14
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15
  "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
 
 
16
  _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
17
  "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
18
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
@@ -93,8 +99,45 @@ func main() {
93
  // Core application variables.
94
  var err error
95
  var cfg *config.Config
96
- var wd string
97
  var isCloudDeploy bool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  // Check for cloud deploy mode only on first execution
100
  // Read env var name in uppercase: DEPLOY
@@ -104,10 +147,44 @@ func main() {
104
  }
105
 
106
  // Determine and load the configuration file.
107
- // If a config path is provided via flags, it is used directly.
108
- // Otherwise, it defaults to "config.yaml" in the current working directory.
109
  var configFilePath string
110
- if configPath != "" {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  configFilePath = configPath
112
  cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
113
  } else {
@@ -121,6 +198,9 @@ func main() {
121
  if err != nil {
122
  log.Fatalf("failed to load config: %v", err)
123
  }
 
 
 
124
 
125
  // In cloud deploy mode, check if we have a valid configuration
126
  var configFileExists bool
@@ -165,7 +245,11 @@ func main() {
165
  }
166
 
167
  // Register the shared token store once so all components use the same persistence backend.
168
- sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
 
 
 
 
169
 
170
  // Register built-in access providers before constructing services.
171
  configaccess.Register()
 
4
  package main
5
 
6
  import (
7
+ "context"
8
+ "errors"
9
  "flag"
10
  "fmt"
11
+ "io/fs"
12
  "os"
13
  "path/filepath"
14
+ "strings"
15
 
16
  configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
17
  "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
18
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19
  "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
20
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
21
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/store"
22
  _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
23
  "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
24
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
 
99
  // Core application variables.
100
  var err error
101
  var cfg *config.Config
 
102
  var isCloudDeploy bool
103
+ var (
104
+ gitStoreLocalPath string
105
+ useGitStore bool
106
+ gitStoreRemoteURL string
107
+ gitStoreUser string
108
+ gitStorePassword string
109
+ gitStoreInst *store.GitTokenStore
110
+ gitStoreRoot string
111
+ )
112
+
113
+ wd, err := os.Getwd()
114
+ if err != nil {
115
+ log.Fatalf("failed to get working directory: %v", err)
116
+ }
117
+
118
+ lookupEnv := func(keys ...string) (string, bool) {
119
+ for _, key := range keys {
120
+ if value, ok := os.LookupEnv(key); ok {
121
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
122
+ return trimmed, true
123
+ }
124
+ }
125
+ }
126
+ return "", false
127
+ }
128
+ if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
129
+ useGitStore = true
130
+ gitStoreRemoteURL = value
131
+ }
132
+ if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
133
+ gitStoreUser = value
134
+ }
135
+ if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
136
+ gitStorePassword = value
137
+ }
138
+ if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
139
+ gitStoreLocalPath = value
140
+ }
141
 
142
  // Check for cloud deploy mode only on first execution
143
  // Read env var name in uppercase: DEPLOY
 
147
  }
148
 
149
  // Determine and load the configuration file.
150
+ // If gitstore is configured, load from the cloned repository; otherwise use the provided path or default.
 
151
  var configFilePath string
152
+ if useGitStore {
153
+ if gitStoreLocalPath == "" {
154
+ gitStoreLocalPath = wd
155
+ }
156
+ gitStoreRoot = filepath.Join(gitStoreLocalPath, "remote")
157
+ authDir := filepath.Join(gitStoreRoot, "auths")
158
+ gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
159
+ gitStoreInst.SetBaseDir(authDir)
160
+ if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
161
+ log.Fatalf("failed to prepare git token store: %v", errRepo)
162
+ }
163
+ configFilePath = gitStoreInst.ConfigPath()
164
+ if configFilePath == "" {
165
+ configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
166
+ }
167
+ if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
168
+ examplePath := filepath.Join(wd, "config.example.yaml")
169
+ if _, errExample := os.Stat(examplePath); errExample != nil {
170
+ log.Fatalf("failed to find template config file: %v", errExample)
171
+ }
172
+ if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
173
+ log.Fatalf("failed to bootstrap git-backed config: %v", errCopy)
174
+ }
175
+ if errCommit := gitStoreInst.CommitConfig(context.Background()); errCommit != nil {
176
+ log.Fatalf("failed to commit initial git-backed config: %v", errCommit)
177
+ }
178
+ log.Infof("git-backed config initialized from template: %s", configFilePath)
179
+ } else if statErr != nil {
180
+ log.Fatalf("failed to inspect git-backed config: %v", statErr)
181
+ }
182
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
183
+ if err == nil {
184
+ cfg.AuthDir = gitStoreInst.AuthDir()
185
+ log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
186
+ }
187
+ } else if configPath != "" {
188
  configFilePath = configPath
189
  cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
190
  } else {
 
198
  if err != nil {
199
  log.Fatalf("failed to load config: %v", err)
200
  }
201
+ if cfg == nil {
202
+ cfg = &config.Config{}
203
+ }
204
 
205
  // In cloud deploy mode, check if we have a valid configuration
206
  var configFileExists bool
 
245
  }
246
 
247
  // Register the shared token store once so all components use the same persistence backend.
248
+ if useGitStore {
249
+ sdkAuth.RegisterTokenStore(gitStoreInst)
250
+ } else {
251
+ sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
252
+ }
253
 
254
  // Register built-in access providers before constructing services.
255
  configaccess.Register()
docker-compose.yml CHANGED
@@ -22,5 +22,4 @@ services:
22
  - ./config.yaml:/CLIProxyAPI/config.yaml
23
  - ./auths:/root/.cli-proxy-api
24
  - ./logs:/CLIProxyAPI/logs
25
- - ./conv:/CLIProxyAPI/conv
26
  restart: unless-stopped
 
22
  - ./config.yaml:/CLIProxyAPI/config.yaml
23
  - ./auths:/root/.cli-proxy-api
24
  - ./logs:/CLIProxyAPI/logs
 
25
  restart: unless-stopped
go.mod CHANGED
@@ -1,49 +1,60 @@
1
  module github.com/router-for-me/CLIProxyAPI/v6
2
 
3
- go 1.24
4
 
5
  require (
6
  github.com/fsnotify/fsnotify v1.9.0
7
  github.com/gin-gonic/gin v1.10.1
 
8
  github.com/google/uuid v1.6.0
 
9
  github.com/sirupsen/logrus v1.9.3
10
  github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
11
  github.com/tidwall/gjson v1.18.0
12
  github.com/tidwall/sjson v1.2.5
13
- go.etcd.io/bbolt v1.3.8
14
- golang.org/x/crypto v0.36.0
15
- golang.org/x/net v0.37.1-0.20250305215238-2914f4677317
16
  golang.org/x/oauth2 v0.30.0
 
17
  gopkg.in/yaml.v3 v3.0.1
18
  )
19
 
20
  require (
21
  cloud.google.com/go/compute/metadata v0.3.0 // indirect
 
 
22
  github.com/bytedance/sonic v1.11.6 // indirect
23
  github.com/bytedance/sonic/loader v0.1.1 // indirect
 
24
  github.com/cloudwego/base64x v0.1.4 // indirect
25
  github.com/cloudwego/iasm v0.2.0 // indirect
 
 
26
  github.com/gabriel-vasile/mimetype v1.4.3 // indirect
27
  github.com/gin-contrib/sse v0.1.0 // indirect
 
 
28
  github.com/go-playground/locales v0.14.1 // indirect
29
  github.com/go-playground/universal-translator v0.18.1 // indirect
30
  github.com/go-playground/validator/v10 v10.20.0 // indirect
31
  github.com/goccy/go-json v0.10.2 // indirect
 
32
  github.com/json-iterator/go v1.1.12 // indirect
33
- github.com/klauspost/compress v1.17.3 // indirect
34
- github.com/klauspost/cpuid/v2 v2.2.7 // indirect
35
  github.com/leodido/go-urn v1.4.0 // indirect
36
  github.com/mattn/go-isatty v0.0.20 // indirect
37
  github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
38
  github.com/modern-go/reflect2 v1.0.2 // indirect
39
  github.com/pelletier/go-toml/v2 v2.2.2 // indirect
 
 
40
  github.com/tidwall/match v1.1.1 // indirect
41
  github.com/tidwall/pretty v1.2.0 // indirect
42
  github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
43
  github.com/ugorji/go/codec v1.2.12 // indirect
44
  golang.org/x/arch v0.8.0 // indirect
45
- golang.org/x/sys v0.31.0 // indirect
46
- golang.org/x/text v0.23.0 // indirect
47
  google.golang.org/protobuf v1.34.1 // indirect
48
- gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
49
  )
 
1
  module github.com/router-for-me/CLIProxyAPI/v6
2
 
3
+ go 1.24.0
4
 
5
  require (
6
  github.com/fsnotify/fsnotify v1.9.0
7
  github.com/gin-gonic/gin v1.10.1
8
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
9
  github.com/google/uuid v1.6.0
10
+ github.com/klauspost/compress v1.17.3
11
  github.com/sirupsen/logrus v1.9.3
12
  github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
13
  github.com/tidwall/gjson v1.18.0
14
  github.com/tidwall/sjson v1.2.5
15
+ golang.org/x/crypto v0.43.0
16
+ golang.org/x/net v0.46.0
 
17
  golang.org/x/oauth2 v0.30.0
18
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
19
  gopkg.in/yaml.v3 v3.0.1
20
  )
21
 
22
  require (
23
  cloud.google.com/go/compute/metadata v0.3.0 // indirect
24
+ github.com/Microsoft/go-winio v0.6.2 // indirect
25
+ github.com/ProtonMail/go-crypto v1.3.0 // indirect
26
  github.com/bytedance/sonic v1.11.6 // indirect
27
  github.com/bytedance/sonic/loader v0.1.1 // indirect
28
+ github.com/cloudflare/circl v1.6.1 // indirect
29
  github.com/cloudwego/base64x v0.1.4 // indirect
30
  github.com/cloudwego/iasm v0.2.0 // indirect
31
+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
32
+ github.com/emirpasic/gods v1.18.1 // indirect
33
  github.com/gabriel-vasile/mimetype v1.4.3 // indirect
34
  github.com/gin-contrib/sse v0.1.0 // indirect
35
+ github.com/go-git/gcfg/v2 v2.0.2 // indirect
36
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
37
  github.com/go-playground/locales v0.14.1 // indirect
38
  github.com/go-playground/universal-translator v0.18.1 // indirect
39
  github.com/go-playground/validator/v10 v10.20.0 // indirect
40
  github.com/goccy/go-json v0.10.2 // indirect
41
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
42
  github.com/json-iterator/go v1.1.12 // indirect
43
+ github.com/kevinburke/ssh_config v1.4.0 // indirect
44
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
45
  github.com/leodido/go-urn v1.4.0 // indirect
46
  github.com/mattn/go-isatty v0.0.20 // indirect
47
  github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
48
  github.com/modern-go/reflect2 v1.0.2 // indirect
49
  github.com/pelletier/go-toml/v2 v2.2.2 // indirect
50
+ github.com/pjbgf/sha1cd v0.5.0 // indirect
51
+ github.com/sergi/go-diff v1.4.0 // indirect
52
  github.com/tidwall/match v1.1.1 // indirect
53
  github.com/tidwall/pretty v1.2.0 // indirect
54
  github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
55
  github.com/ugorji/go/codec v1.2.12 // indirect
56
  golang.org/x/arch v0.8.0 // indirect
57
+ golang.org/x/sys v0.37.0 // indirect
58
+ golang.org/x/text v0.30.0 // indirect
59
  google.golang.org/protobuf v1.34.1 // indirect
 
60
  )
go.sum CHANGED
@@ -1,16 +1,32 @@
1
  cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
2
  cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
 
 
 
 
 
 
 
 
3
  github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
4
  github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
5
  github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
6
  github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
 
 
7
  github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
8
  github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
9
  github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
10
  github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
 
 
11
  github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12
  github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
13
  github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 
 
 
 
14
  github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
15
  github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
16
  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
19
  github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
20
  github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
21
  github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
 
 
 
 
 
 
 
 
 
 
22
  github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
23
  github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
24
  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
29
  github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
30
  github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
31
  github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 
 
32
  github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
33
  github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
34
  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=
36
  github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
37
  github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
38
  github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 
 
39
  github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
40
  github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
41
  github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
42
- github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
43
- github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
44
  github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
 
 
 
 
 
 
45
  github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
46
  github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
47
  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
53
  github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
54
  github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
55
  github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 
 
56
  github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
57
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 
 
 
 
58
  github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
59
  github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
60
  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
64
  github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
65
  github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
66
  github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 
67
  github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
68
  github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
69
  github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
70
  github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
71
  github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
72
- github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
73
  github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 
 
74
  github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
75
  github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
76
  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
84
  github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
85
  github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
86
  github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
87
- go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
88
- go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
89
  golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
90
  golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
91
  golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
92
- golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
93
- golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
94
- golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 h1:wneCP+2d9NUmndnyTmY7VwUNYiP26xiN/AtdcojQ1lI=
95
- golang.org/x/net v0.37.1-0.20250305215238-2914f4677317/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
96
  golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
97
  golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
98
  golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99
- golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100
  golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
101
- golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
102
- golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
103
- golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
104
- golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 
 
105
  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
106
  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
107
  google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
108
  google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
109
- gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
110
  gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 
 
 
111
  gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
112
  gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
 
 
113
  gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
114
  gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
115
  gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 
1
  cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
2
  cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
3
+ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
4
+ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
5
+ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
6
+ github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
7
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
8
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
9
+ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
10
+ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
11
  github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
12
  github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
13
  github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
14
  github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
15
+ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
16
+ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
17
  github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
18
  github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
19
  github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
20
  github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
21
+ github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
22
+ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
23
  github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
24
  github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
25
  github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
26
+ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
27
+ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
28
+ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
29
+ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
30
  github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
31
  github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
32
  github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 
35
  github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
36
  github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
37
  github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
38
+ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
39
+ github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
40
+ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
41
+ github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
42
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
43
+ github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
44
+ github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
45
+ github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
46
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
47
+ github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
48
  github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
49
  github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
50
  github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 
55
  github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
56
  github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
57
  github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
58
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
59
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
60
  github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
61
  github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
62
  github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 
64
  github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
65
  github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
66
  github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
67
+ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
68
+ github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
69
  github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
70
  github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
71
  github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
72
+ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
73
+ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
74
  github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
75
+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
76
+ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
77
+ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
78
+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
79
+ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
80
+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
81
  github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
82
  github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
83
  github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 
89
  github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
90
  github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
91
  github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
92
+ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
93
+ github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
94
  github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
95
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
96
+ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
97
+ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
98
+ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
99
+ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
100
  github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
101
  github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
102
  github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
 
106
  github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
107
  github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
108
  github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
109
+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
110
  github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
111
  github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
112
  github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
113
  github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
114
  github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 
115
  github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
116
+ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
117
+ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
118
  github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
119
  github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
120
  github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 
128
  github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
129
  github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
130
  github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
 
 
131
  golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
132
  golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
133
  golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
134
+ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
135
+ golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
136
+ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
137
+ golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
138
  golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
139
  golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
140
  golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 
141
  golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
142
+ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
143
+ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
144
+ golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
145
+ golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
146
+ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
147
+ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
148
  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
149
  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
150
  google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
151
  google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 
152
  gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
153
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
154
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
155
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
156
  gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
157
  gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
158
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
159
+ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
160
  gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
161
  gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
162
  gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
internal/api/handlers/management/handler.go CHANGED
@@ -6,6 +6,7 @@ import (
6
  "crypto/subtle"
7
  "fmt"
8
  "net/http"
 
9
  "strings"
10
  "sync"
11
  "time"
@@ -25,28 +26,33 @@ type attemptInfo struct {
25
 
26
  // Handler aggregates config reference, persistence path and helpers.
27
  type Handler struct {
28
- cfg *config.Config
29
- configFilePath string
30
- mu sync.Mutex
31
-
32
- attemptsMu sync.Mutex
33
- failedAttempts map[string]*attemptInfo // keyed by client IP
34
- authManager *coreauth.Manager
35
- usageStats *usage.RequestStatistics
36
- tokenStore coreauth.Store
37
-
38
- localPassword string
39
  }
40
 
41
  // NewHandler creates a new management handler instance.
42
  func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
 
 
 
43
  return &Handler{
44
- cfg: cfg,
45
- configFilePath: configFilePath,
46
- failedAttempts: make(map[string]*attemptInfo),
47
- authManager: manager,
48
- usageStats: usage.GetRequestStatistics(),
49
- tokenStore: sdkAuth.GetTokenStore(),
 
 
50
  }
51
  }
52
 
@@ -72,6 +78,19 @@ func (h *Handler) Middleware() gin.HandlerFunc {
72
  return func(c *gin.Context) {
73
  clientIP := c.ClientIP()
74
  localClient := clientIP == "127.0.0.1" || clientIP == "::1"
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  fail := func() {}
77
  if !localClient {
@@ -92,7 +111,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
92
  }
93
  h.attemptsMu.Unlock()
94
 
95
- if !h.cfg.RemoteManagement.AllowRemote {
96
  c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
97
  return
98
  }
@@ -112,8 +131,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
112
  h.attemptsMu.Unlock()
113
  }
114
  }
115
- secret := h.cfg.RemoteManagement.SecretKey
116
- if secret == "" {
117
  c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
118
  return
119
  }
@@ -149,7 +167,20 @@ func (h *Handler) Middleware() gin.HandlerFunc {
149
  }
150
  }
151
 
152
- if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  if !localClient {
154
  fail()
155
  }
 
6
  "crypto/subtle"
7
  "fmt"
8
  "net/http"
9
+ "os"
10
  "strings"
11
  "sync"
12
  "time"
 
26
 
27
  // Handler aggregates config reference, persistence path and helpers.
28
  type Handler struct {
29
+ cfg *config.Config
30
+ configFilePath string
31
+ mu sync.Mutex
32
+ attemptsMu sync.Mutex
33
+ failedAttempts map[string]*attemptInfo // keyed by client IP
34
+ authManager *coreauth.Manager
35
+ usageStats *usage.RequestStatistics
36
+ tokenStore coreauth.Store
37
+ localPassword string
38
+ allowRemoteOverride bool
39
+ envSecret string
40
  }
41
 
42
  // NewHandler creates a new management handler instance.
43
  func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
44
+ envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
45
+ envSecret = strings.TrimSpace(envSecret)
46
+
47
  return &Handler{
48
+ cfg: cfg,
49
+ configFilePath: configFilePath,
50
+ failedAttempts: make(map[string]*attemptInfo),
51
+ authManager: manager,
52
+ usageStats: usage.GetRequestStatistics(),
53
+ tokenStore: sdkAuth.GetTokenStore(),
54
+ allowRemoteOverride: envSecret != "",
55
+ envSecret: envSecret,
56
  }
57
  }
58
 
 
78
  return func(c *gin.Context) {
79
  clientIP := c.ClientIP()
80
  localClient := clientIP == "127.0.0.1" || clientIP == "::1"
81
+ cfg := h.cfg
82
+ var (
83
+ allowRemote bool
84
+ secretHash string
85
+ )
86
+ if cfg != nil {
87
+ allowRemote = cfg.RemoteManagement.AllowRemote
88
+ secretHash = cfg.RemoteManagement.SecretKey
89
+ }
90
+ if h.allowRemoteOverride {
91
+ allowRemote = true
92
+ }
93
+ envSecret := h.envSecret
94
 
95
  fail := func() {}
96
  if !localClient {
 
111
  }
112
  h.attemptsMu.Unlock()
113
 
114
+ if !allowRemote {
115
  c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
116
  return
117
  }
 
131
  h.attemptsMu.Unlock()
132
  }
133
  }
134
+ if secretHash == "" && envSecret == "" {
 
135
  c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
136
  return
137
  }
 
167
  }
168
  }
169
 
170
+ if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
171
+ if !localClient {
172
+ h.attemptsMu.Lock()
173
+ if ai := h.failedAttempts[clientIP]; ai != nil {
174
+ ai.count = 0
175
+ ai.blockedUntil = time.Time{}
176
+ }
177
+ h.attemptsMu.Unlock()
178
+ }
179
+ c.Next()
180
+ return
181
+ }
182
+
183
+ if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
184
  if !localClient {
185
  fail()
186
  }
internal/api/server.go CHANGED
@@ -126,6 +126,9 @@ type Server struct {
126
  // configFilePath is the absolute path to the YAML config file for persistence.
127
  configFilePath string
128
 
 
 
 
129
  // management handler
130
  mgmt *managementHandlers.Handler
131
 
@@ -134,6 +137,9 @@ type Server struct {
134
  // managementRoutesEnabled controls whether management endpoints serve real handlers.
135
  managementRoutesEnabled atomic.Bool
136
 
 
 
 
137
  localPassword string
138
 
139
  keepAliveEnabled bool
@@ -193,16 +199,26 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
193
  }
194
 
195
  engine.Use(corsMiddleware())
 
 
 
 
 
 
 
 
196
 
197
  // Create server instance
198
  s := &Server{
199
- engine: engine,
200
- handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
201
- cfg: cfg,
202
- accessManager: accessManager,
203
- requestLogger: requestLogger,
204
- loggerToggle: toggle,
205
- configFilePath: configFilePath,
 
 
206
  }
207
  s.applyAccessConfig(nil, cfg)
208
  // Initialize management handler
@@ -218,9 +234,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
218
  optionState.routerConfigurator(engine, s.handlers, cfg)
219
  }
220
 
221
- // Register management routes only when a secret is present at startup.
222
- s.managementRoutesEnabled.Store(cfg.RemoteManagement.SecretKey != "")
223
- if cfg.RemoteManagement.SecretKey != "" {
 
224
  s.registerManagementRoutes()
225
  }
226
 
@@ -272,7 +289,6 @@ func (s *Server) setupRoutes() {
272
  s.engine.GET("/", func(c *gin.Context) {
273
  c.JSON(http.StatusOK, gin.H{
274
  "message": "CLI Proxy API Server",
275
- "version": "1.0.0",
276
  "endpoints": []string{
277
  "POST /v1/chat/completions",
278
  "POST /v1/completions",
@@ -441,8 +457,8 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
441
  c.AbortWithStatus(http.StatusNotFound)
442
  return
443
  }
444
-
445
- filePath := managementasset.FilePath(s.configFilePath)
446
  if strings.TrimSpace(filePath) == "" {
447
  c.AbortWithStatus(http.StatusNotFound)
448
  return
@@ -450,7 +466,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
450
 
451
  if _, err := os.Stat(filePath); err != nil {
452
  if os.IsNotExist(err) {
453
- go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
454
  c.AbortWithStatus(http.StatusNotFound)
455
  return
456
  }
@@ -691,22 +707,31 @@ func (s *Server) UpdateClients(cfg *config.Config) {
691
  prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
692
  }
693
  newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
694
- switch {
695
- case prevSecretEmpty && !newSecretEmpty:
696
  s.registerManagementRoutes()
697
  if s.managementRoutesEnabled.CompareAndSwap(false, true) {
698
- log.Info("management routes enabled after secret key update")
699
  } else {
700
  s.managementRoutesEnabled.Store(true)
701
  }
702
- case !prevSecretEmpty && newSecretEmpty:
703
- if s.managementRoutesEnabled.CompareAndSwap(true, false) {
704
- log.Info("management routes disabled after secret key removal")
705
- } else {
706
- s.managementRoutesEnabled.Store(false)
 
 
 
 
 
 
 
 
 
 
 
 
707
  }
708
- default:
709
- s.managementRoutesEnabled.Store(!newSecretEmpty)
710
  }
711
 
712
  s.applyAccessConfig(oldCfg, cfg)
@@ -714,7 +739,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
714
  s.handlers.UpdateClients(&cfg.SDKConfig)
715
 
716
  if !cfg.RemoteManagement.DisableControlPanel {
717
- staticDir := managementasset.StaticDir(s.configFilePath)
718
  go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
719
  }
720
  if s.mgmt != nil {
 
126
  // configFilePath is the absolute path to the YAML config file for persistence.
127
  configFilePath string
128
 
129
+ // currentPath is the absolute path to the current working directory.
130
+ currentPath string
131
+
132
  // management handler
133
  mgmt *managementHandlers.Handler
134
 
 
137
  // managementRoutesEnabled controls whether management endpoints serve real handlers.
138
  managementRoutesEnabled atomic.Bool
139
 
140
+ // envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.
141
+ envManagementSecret bool
142
+
143
  localPassword string
144
 
145
  keepAliveEnabled bool
 
199
  }
200
 
201
  engine.Use(corsMiddleware())
202
+ wd, err := os.Getwd()
203
+ if err != nil {
204
+ wd = configFilePath
205
+ }
206
+
207
+ envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD")
208
+ envAdminPassword = strings.TrimSpace(envAdminPassword)
209
+ envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
210
 
211
  // Create server instance
212
  s := &Server{
213
+ engine: engine,
214
+ handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
215
+ cfg: cfg,
216
+ accessManager: accessManager,
217
+ requestLogger: requestLogger,
218
+ loggerToggle: toggle,
219
+ configFilePath: configFilePath,
220
+ currentPath: wd,
221
+ envManagementSecret: envManagementSecret,
222
  }
223
  s.applyAccessConfig(nil, cfg)
224
  // Initialize management handler
 
234
  optionState.routerConfigurator(engine, s.handlers, cfg)
235
  }
236
 
237
+ // Register management routes when configuration or environment secrets are available.
238
+ hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret
239
+ s.managementRoutesEnabled.Store(hasManagementSecret)
240
+ if hasManagementSecret {
241
  s.registerManagementRoutes()
242
  }
243
 
 
289
  s.engine.GET("/", func(c *gin.Context) {
290
  c.JSON(http.StatusOK, gin.H{
291
  "message": "CLI Proxy API Server",
 
292
  "endpoints": []string{
293
  "POST /v1/chat/completions",
294
  "POST /v1/completions",
 
457
  c.AbortWithStatus(http.StatusNotFound)
458
  return
459
  }
460
+ println(s.currentPath)
461
+ filePath := managementasset.FilePath(s.currentPath)
462
  if strings.TrimSpace(filePath) == "" {
463
  c.AbortWithStatus(http.StatusNotFound)
464
  return
 
466
 
467
  if _, err := os.Stat(filePath); err != nil {
468
  if os.IsNotExist(err) {
469
+ go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.currentPath), cfg.ProxyURL)
470
  c.AbortWithStatus(http.StatusNotFound)
471
  return
472
  }
 
707
  prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
708
  }
709
  newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
710
+ if s.envManagementSecret {
 
711
  s.registerManagementRoutes()
712
  if s.managementRoutesEnabled.CompareAndSwap(false, true) {
713
+ log.Info("management routes enabled via MANAGEMENT_PASSWORD")
714
  } else {
715
  s.managementRoutesEnabled.Store(true)
716
  }
717
+ } else {
718
+ switch {
719
+ case prevSecretEmpty && !newSecretEmpty:
720
+ s.registerManagementRoutes()
721
+ if s.managementRoutesEnabled.CompareAndSwap(false, true) {
722
+ log.Info("management routes enabled after secret key update")
723
+ } else {
724
+ s.managementRoutesEnabled.Store(true)
725
+ }
726
+ case !prevSecretEmpty && newSecretEmpty:
727
+ if s.managementRoutesEnabled.CompareAndSwap(true, false) {
728
+ log.Info("management routes disabled after secret key removal")
729
+ } else {
730
+ s.managementRoutesEnabled.Store(false)
731
+ }
732
+ default:
733
+ s.managementRoutesEnabled.Store(!newSecretEmpty)
734
  }
 
 
735
  }
736
 
737
  s.applyAccessConfig(oldCfg, cfg)
 
739
  s.handlers.UpdateClients(&cfg.SDKConfig)
740
 
741
  if !cfg.RemoteManagement.DisableControlPanel {
742
+ staticDir := managementasset.StaticDir(s.currentPath)
743
  go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
744
  }
745
  if s.mgmt != nil {
internal/managementasset/updater.go CHANGED
@@ -60,7 +60,15 @@ func StaticDir(configFilePath string) string {
60
  if configFilePath == "" {
61
  return ""
62
  }
 
63
  base := filepath.Dir(configFilePath)
 
 
 
 
 
 
 
64
  return filepath.Join(base, "static")
65
  }
66
 
 
60
  if configFilePath == "" {
61
  return ""
62
  }
63
+
64
  base := filepath.Dir(configFilePath)
65
+ fileInfo, err := os.Stat(configFilePath)
66
+ if err == nil {
67
+ if fileInfo.IsDir() {
68
+ base = configFilePath
69
+ }
70
+ }
71
+
72
  return filepath.Join(base, "static")
73
  }
74
 
internal/misc/copy-example-config.go ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package misc
2
+
3
+ import (
4
+ "io"
5
+ "os"
6
+ "path/filepath"
7
+
8
+ log "github.com/sirupsen/logrus"
9
+ )
10
+
11
+ func CopyConfigTemplate(src, dst string) error {
12
+ in, err := os.Open(src)
13
+ if err != nil {
14
+ return err
15
+ }
16
+ defer func() {
17
+ if errClose := in.Close(); errClose != nil {
18
+ log.WithError(errClose).Warn("failed to close source config file")
19
+ }
20
+ }()
21
+
22
+ if err = os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
23
+ return err
24
+ }
25
+
26
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
27
+ if err != nil {
28
+ return err
29
+ }
30
+ defer func() {
31
+ if errClose := out.Close(); errClose != nil {
32
+ log.WithError(errClose).Warn("failed to close destination config file")
33
+ }
34
+ }()
35
+
36
+ if _, err = io.Copy(out, in); err != nil {
37
+ return err
38
+ }
39
+ return out.Sync()
40
+ }
internal/store/gitstore.go ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io/fs"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/go-git/go-git/v6"
16
+ "github.com/go-git/go-git/v6/config"
17
+ "github.com/go-git/go-git/v6/plumbing"
18
+ "github.com/go-git/go-git/v6/plumbing/object"
19
+ "github.com/go-git/go-git/v6/plumbing/transport"
20
+ "github.com/go-git/go-git/v6/plumbing/transport/http"
21
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
22
+ )
23
+
24
+ // GitTokenStore persists token records and auth metadata using git as the backing storage.
25
+ type GitTokenStore struct {
26
+ mu sync.Mutex
27
+ dirLock sync.RWMutex
28
+ baseDir string
29
+ repoDir string
30
+ configDir string
31
+ remote string
32
+ username string
33
+ password string
34
+ }
35
+
36
+ // NewGitTokenStore creates a token store that saves credentials to disk through the
37
+ // TokenStorage implementation embedded in the token record.
38
+ func NewGitTokenStore(remote, username, password string) *GitTokenStore {
39
+ return &GitTokenStore{
40
+ remote: remote,
41
+ username: username,
42
+ password: password,
43
+ }
44
+ }
45
+
46
+ // SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
47
+ func (s *GitTokenStore) SetBaseDir(dir string) {
48
+ clean := strings.TrimSpace(dir)
49
+ if clean == "" {
50
+ s.dirLock.Lock()
51
+ s.baseDir = ""
52
+ s.repoDir = ""
53
+ s.configDir = ""
54
+ s.dirLock.Unlock()
55
+ return
56
+ }
57
+ if abs, err := filepath.Abs(clean); err == nil {
58
+ clean = abs
59
+ }
60
+ repoDir := filepath.Dir(clean)
61
+ if repoDir == "" || repoDir == "." {
62
+ repoDir = clean
63
+ }
64
+ configDir := filepath.Join(repoDir, "config")
65
+ s.dirLock.Lock()
66
+ s.baseDir = clean
67
+ s.repoDir = repoDir
68
+ s.configDir = configDir
69
+ s.dirLock.Unlock()
70
+ }
71
+
72
+ // AuthDir returns the directory used for auth persistence.
73
+ func (s *GitTokenStore) AuthDir() string {
74
+ return s.baseDirSnapshot()
75
+ }
76
+
77
+ // ConfigPath returns the managed config file path.
78
+ func (s *GitTokenStore) ConfigPath() string {
79
+ s.dirLock.RLock()
80
+ defer s.dirLock.RUnlock()
81
+ if s.configDir == "" {
82
+ return ""
83
+ }
84
+ return filepath.Join(s.configDir, "config.yaml")
85
+ }
86
+
87
+ // EnsureRepository prepares the local git working tree by cloning or opening the repository.
88
+ func (s *GitTokenStore) EnsureRepository() error {
89
+ s.dirLock.Lock()
90
+ if s.remote == "" {
91
+ s.dirLock.Unlock()
92
+ return fmt.Errorf("git token store: remote not configured")
93
+ }
94
+ if s.baseDir == "" {
95
+ s.dirLock.Unlock()
96
+ return fmt.Errorf("git token store: base directory not configured")
97
+ }
98
+ repoDir := s.repoDir
99
+ if repoDir == "" {
100
+ repoDir = filepath.Dir(s.baseDir)
101
+ if repoDir == "" || repoDir == "." {
102
+ repoDir = s.baseDir
103
+ }
104
+ s.repoDir = repoDir
105
+ }
106
+ if s.configDir == "" {
107
+ s.configDir = filepath.Join(repoDir, "config")
108
+ }
109
+ authDir := filepath.Join(repoDir, "auths")
110
+ configDir := filepath.Join(repoDir, "config")
111
+ gitDir := filepath.Join(repoDir, ".git")
112
+ authMethod := s.gitAuth()
113
+ var initPaths []string
114
+ if _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) {
115
+ if errMk := os.MkdirAll(repoDir, 0o700); errMk != nil {
116
+ s.dirLock.Unlock()
117
+ return fmt.Errorf("git token store: create repo dir: %w", errMk)
118
+ }
119
+ if _, errClone := git.PlainClone(repoDir, &git.CloneOptions{Auth: authMethod, URL: s.remote}); errClone != nil {
120
+ if errors.Is(errClone, transport.ErrEmptyRemoteRepository) {
121
+ _ = os.RemoveAll(gitDir)
122
+ repo, errInit := git.PlainInit(repoDir, false)
123
+ if errInit != nil {
124
+ s.dirLock.Unlock()
125
+ return fmt.Errorf("git token store: init empty repo: %w", errInit)
126
+ }
127
+ if _, errRemote := repo.Remote("origin"); errRemote != nil {
128
+ if _, errCreate := repo.CreateRemote(&config.RemoteConfig{
129
+ Name: "origin",
130
+ URLs: []string{s.remote},
131
+ }); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) {
132
+ s.dirLock.Unlock()
133
+ return fmt.Errorf("git token store: configure remote: %w", errCreate)
134
+ }
135
+ }
136
+ if err := os.MkdirAll(authDir, 0o700); err != nil {
137
+ s.dirLock.Unlock()
138
+ return fmt.Errorf("git token store: create auth dir: %w", err)
139
+ }
140
+ if err := os.MkdirAll(configDir, 0o700); err != nil {
141
+ s.dirLock.Unlock()
142
+ return fmt.Errorf("git token store: create config dir: %w", err)
143
+ }
144
+ if err := ensureEmptyFile(filepath.Join(authDir, ".gitkeep")); err != nil {
145
+ s.dirLock.Unlock()
146
+ return fmt.Errorf("git token store: create auth placeholder: %w", err)
147
+ }
148
+ if err := ensureEmptyFile(filepath.Join(configDir, ".gitkeep")); err != nil {
149
+ s.dirLock.Unlock()
150
+ return fmt.Errorf("git token store: create config placeholder: %w", err)
151
+ }
152
+ initPaths = []string{
153
+ filepath.Join("auths", ".gitkeep"),
154
+ filepath.Join("config", ".gitkeep"),
155
+ }
156
+ } else {
157
+ s.dirLock.Unlock()
158
+ return fmt.Errorf("git token store: clone remote: %w", errClone)
159
+ }
160
+ }
161
+ } else if err != nil {
162
+ s.dirLock.Unlock()
163
+ return fmt.Errorf("git token store: stat repo: %w", err)
164
+ } else {
165
+ repo, errOpen := git.PlainOpen(repoDir)
166
+ if errOpen != nil {
167
+ s.dirLock.Unlock()
168
+ return fmt.Errorf("git token store: open repo: %w", errOpen)
169
+ }
170
+ worktree, errWorktree := repo.Worktree()
171
+ if errWorktree != nil {
172
+ s.dirLock.Unlock()
173
+ return fmt.Errorf("git token store: worktree: %w", errWorktree)
174
+ }
175
+ if errPull := worktree.Pull(&git.PullOptions{Auth: authMethod, RemoteName: "origin"}); errPull != nil {
176
+ switch {
177
+ case errors.Is(errPull, git.NoErrAlreadyUpToDate),
178
+ errors.Is(errPull, git.ErrUnstagedChanges),
179
+ errors.Is(errPull, git.ErrNonFastForwardUpdate):
180
+ // Ignore clean syncs, local edits, and remote divergence—local changes win.
181
+ case errors.Is(errPull, transport.ErrAuthenticationRequired),
182
+ errors.Is(errPull, plumbing.ErrReferenceNotFound),
183
+ errors.Is(errPull, transport.ErrEmptyRemoteRepository):
184
+ // Ignore authentication prompts and empty remote references on initial sync.
185
+ default:
186
+ s.dirLock.Unlock()
187
+ return fmt.Errorf("git token store: pull: %w", errPull)
188
+ }
189
+ }
190
+ }
191
+ if err := os.MkdirAll(s.baseDir, 0o700); err != nil {
192
+ s.dirLock.Unlock()
193
+ return fmt.Errorf("git token store: create auth dir: %w", err)
194
+ }
195
+ if err := os.MkdirAll(s.configDir, 0o700); err != nil {
196
+ s.dirLock.Unlock()
197
+ return fmt.Errorf("git token store: create config dir: %w", err)
198
+ }
199
+ s.dirLock.Unlock()
200
+ if len(initPaths) > 0 {
201
+ s.mu.Lock()
202
+ err := s.commitAndPushLocked("Initialize git token store", initPaths...)
203
+ s.mu.Unlock()
204
+ if err != nil {
205
+ return err
206
+ }
207
+ }
208
+ return nil
209
+ }
210
+
211
+ // Save persists token storage and metadata to the resolved auth file path.
212
+ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string, error) {
213
+ if auth == nil {
214
+ return "", fmt.Errorf("auth filestore: auth is nil")
215
+ }
216
+
217
+ path, err := s.resolveAuthPath(auth)
218
+ if err != nil {
219
+ return "", err
220
+ }
221
+ if path == "" {
222
+ return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
223
+ }
224
+
225
+ if auth.Disabled {
226
+ if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
227
+ return "", nil
228
+ }
229
+ }
230
+
231
+ if err = s.EnsureRepository(); err != nil {
232
+ return "", err
233
+ }
234
+
235
+ s.mu.Lock()
236
+ defer s.mu.Unlock()
237
+
238
+ if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
239
+ return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
240
+ }
241
+
242
+ switch {
243
+ case auth.Storage != nil:
244
+ if err = auth.Storage.SaveTokenToFile(path); err != nil {
245
+ return "", err
246
+ }
247
+ case auth.Metadata != nil:
248
+ raw, errMarshal := json.Marshal(auth.Metadata)
249
+ if errMarshal != nil {
250
+ return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
251
+ }
252
+ if existing, errRead := os.ReadFile(path); errRead == nil {
253
+ if jsonEqual(existing, raw) {
254
+ return path, nil
255
+ }
256
+ } else if !os.IsNotExist(errRead) {
257
+ return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
258
+ }
259
+ tmp := path + ".tmp"
260
+ if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
261
+ return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
262
+ }
263
+ if errRename := os.Rename(tmp, path); errRename != nil {
264
+ return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
265
+ }
266
+ default:
267
+ return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
268
+ }
269
+
270
+ if auth.Attributes == nil {
271
+ auth.Attributes = make(map[string]string)
272
+ }
273
+ auth.Attributes["path"] = path
274
+
275
+ if strings.TrimSpace(auth.FileName) == "" {
276
+ auth.FileName = auth.ID
277
+ }
278
+
279
+ relPath, errRel := s.relativeToRepo(path)
280
+ if errRel != nil {
281
+ return "", errRel
282
+ }
283
+ messageID := auth.ID
284
+ if strings.TrimSpace(messageID) == "" {
285
+ messageID = filepath.Base(path)
286
+ }
287
+ if errCommit := s.commitAndPushLocked(fmt.Sprintf("Update auth %s", strings.TrimSpace(messageID)), relPath); errCommit != nil {
288
+ return "", errCommit
289
+ }
290
+
291
+ return path, nil
292
+ }
293
+
294
+ // List enumerates all auth JSON files under the configured directory.
295
+ func (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
296
+ if err := s.EnsureRepository(); err != nil {
297
+ return nil, err
298
+ }
299
+ dir := s.baseDirSnapshot()
300
+ if dir == "" {
301
+ return nil, fmt.Errorf("auth filestore: directory not configured")
302
+ }
303
+ entries := make([]*cliproxyauth.Auth, 0)
304
+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
305
+ if walkErr != nil {
306
+ return walkErr
307
+ }
308
+ if d.IsDir() {
309
+ return nil
310
+ }
311
+ if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
312
+ return nil
313
+ }
314
+ auth, err := s.readAuthFile(path, dir)
315
+ if err != nil {
316
+ return nil
317
+ }
318
+ if auth != nil {
319
+ entries = append(entries, auth)
320
+ }
321
+ return nil
322
+ })
323
+ if err != nil {
324
+ return nil, err
325
+ }
326
+ return entries, nil
327
+ }
328
+
329
+ // Delete removes the auth file.
330
+ func (s *GitTokenStore) Delete(_ context.Context, id string) error {
331
+ id = strings.TrimSpace(id)
332
+ if id == "" {
333
+ return fmt.Errorf("auth filestore: id is empty")
334
+ }
335
+ path, err := s.resolveDeletePath(id)
336
+ if err != nil {
337
+ return err
338
+ }
339
+ if err = s.EnsureRepository(); err != nil {
340
+ return err
341
+ }
342
+
343
+ s.mu.Lock()
344
+ defer s.mu.Unlock()
345
+
346
+ if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
347
+ return fmt.Errorf("auth filestore: delete failed: %w", err)
348
+ }
349
+ if err == nil {
350
+ rel, errRel := s.relativeToRepo(path)
351
+ if errRel != nil {
352
+ return errRel
353
+ }
354
+ messageID := id
355
+ if errCommit := s.commitAndPushLocked(fmt.Sprintf("Delete auth %s", messageID), rel); errCommit != nil {
356
+ return errCommit
357
+ }
358
+ }
359
+ return nil
360
+ }
361
+
362
+ // CommitPaths commits and pushes the provided paths to the remote repository.
363
+ // It no-ops when the store is not fully configured or when there are no paths.
364
+ func (s *GitTokenStore) CommitPaths(_ context.Context, message string, paths ...string) error {
365
+ if len(paths) == 0 {
366
+ return nil
367
+ }
368
+ if err := s.EnsureRepository(); err != nil {
369
+ return err
370
+ }
371
+
372
+ filtered := make([]string, 0, len(paths))
373
+ for _, p := range paths {
374
+ trimmed := strings.TrimSpace(p)
375
+ if trimmed == "" {
376
+ continue
377
+ }
378
+ rel, err := s.relativeToRepo(trimmed)
379
+ if err != nil {
380
+ return err
381
+ }
382
+ filtered = append(filtered, rel)
383
+ }
384
+ if len(filtered) == 0 {
385
+ return nil
386
+ }
387
+
388
+ s.mu.Lock()
389
+ defer s.mu.Unlock()
390
+
391
+ if strings.TrimSpace(message) == "" {
392
+ message = "Sync watcher updates"
393
+ }
394
+ return s.commitAndPushLocked(message, filtered...)
395
+ }
396
+
397
+ func (s *GitTokenStore) resolveDeletePath(id string) (string, error) {
398
+ if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
399
+ return id, nil
400
+ }
401
+ dir := s.baseDirSnapshot()
402
+ if dir == "" {
403
+ return "", fmt.Errorf("auth filestore: directory not configured")
404
+ }
405
+ return filepath.Join(dir, id), nil
406
+ }
407
+
408
+ func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
409
+ data, err := os.ReadFile(path)
410
+ if err != nil {
411
+ return nil, fmt.Errorf("read file: %w", err)
412
+ }
413
+ if len(data) == 0 {
414
+ return nil, nil
415
+ }
416
+ metadata := make(map[string]any)
417
+ if err = json.Unmarshal(data, &metadata); err != nil {
418
+ return nil, fmt.Errorf("unmarshal auth json: %w", err)
419
+ }
420
+ provider, _ := metadata["type"].(string)
421
+ if provider == "" {
422
+ provider = "unknown"
423
+ }
424
+ info, err := os.Stat(path)
425
+ if err != nil {
426
+ return nil, fmt.Errorf("stat file: %w", err)
427
+ }
428
+ id := s.idFor(path, baseDir)
429
+ auth := &cliproxyauth.Auth{
430
+ ID: id,
431
+ Provider: provider,
432
+ FileName: id,
433
+ Label: s.labelFor(metadata),
434
+ Status: cliproxyauth.StatusActive,
435
+ Attributes: map[string]string{"path": path},
436
+ Metadata: metadata,
437
+ CreatedAt: info.ModTime(),
438
+ UpdatedAt: info.ModTime(),
439
+ LastRefreshedAt: time.Time{},
440
+ NextRefreshAfter: time.Time{},
441
+ }
442
+ if email, ok := metadata["email"].(string); ok && email != "" {
443
+ auth.Attributes["email"] = email
444
+ }
445
+ return auth, nil
446
+ }
447
+
448
+ func (s *GitTokenStore) idFor(path, baseDir string) string {
449
+ if baseDir == "" {
450
+ return path
451
+ }
452
+ rel, err := filepath.Rel(baseDir, path)
453
+ if err != nil {
454
+ return path
455
+ }
456
+ return rel
457
+ }
458
+
459
+ func (s *GitTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
460
+ if auth == nil {
461
+ return "", fmt.Errorf("auth filestore: auth is nil")
462
+ }
463
+ if auth.Attributes != nil {
464
+ if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
465
+ return p, nil
466
+ }
467
+ }
468
+ if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
469
+ if filepath.IsAbs(fileName) {
470
+ return fileName, nil
471
+ }
472
+ if dir := s.baseDirSnapshot(); dir != "" {
473
+ return filepath.Join(dir, fileName), nil
474
+ }
475
+ return fileName, nil
476
+ }
477
+ if auth.ID == "" {
478
+ return "", fmt.Errorf("auth filestore: missing id")
479
+ }
480
+ if filepath.IsAbs(auth.ID) {
481
+ return auth.ID, nil
482
+ }
483
+ dir := s.baseDirSnapshot()
484
+ if dir == "" {
485
+ return "", fmt.Errorf("auth filestore: directory not configured")
486
+ }
487
+ return filepath.Join(dir, auth.ID), nil
488
+ }
489
+
490
+ func (s *GitTokenStore) labelFor(metadata map[string]any) string {
491
+ if metadata == nil {
492
+ return ""
493
+ }
494
+ if v, ok := metadata["label"].(string); ok && v != "" {
495
+ return v
496
+ }
497
+ if v, ok := metadata["email"].(string); ok && v != "" {
498
+ return v
499
+ }
500
+ if project, ok := metadata["project_id"].(string); ok && project != "" {
501
+ return project
502
+ }
503
+ return ""
504
+ }
505
+
506
+ func (s *GitTokenStore) baseDirSnapshot() string {
507
+ s.dirLock.RLock()
508
+ defer s.dirLock.RUnlock()
509
+ return s.baseDir
510
+ }
511
+
512
+ func (s *GitTokenStore) repoDirSnapshot() string {
513
+ s.dirLock.RLock()
514
+ defer s.dirLock.RUnlock()
515
+ return s.repoDir
516
+ }
517
+
518
+ func (s *GitTokenStore) gitAuth() transport.AuthMethod {
519
+ if s.username == "" && s.password == "" {
520
+ return nil
521
+ }
522
+ user := s.username
523
+ if user == "" {
524
+ user = "git"
525
+ }
526
+ return &http.BasicAuth{Username: user, Password: s.password}
527
+ }
528
+
529
+ func (s *GitTokenStore) relativeToRepo(path string) (string, error) {
530
+ repoDir := s.repoDirSnapshot()
531
+ if repoDir == "" {
532
+ return "", fmt.Errorf("git token store: repository path not configured")
533
+ }
534
+ absRepo := repoDir
535
+ if abs, err := filepath.Abs(repoDir); err == nil {
536
+ absRepo = abs
537
+ }
538
+ cleanPath := path
539
+ if abs, err := filepath.Abs(path); err == nil {
540
+ cleanPath = abs
541
+ }
542
+ rel, err := filepath.Rel(absRepo, cleanPath)
543
+ if err != nil {
544
+ return "", fmt.Errorf("git token store: relative path: %w", err)
545
+ }
546
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
547
+ return "", fmt.Errorf("git token store: path outside repository")
548
+ }
549
+ return rel, nil
550
+ }
551
+
552
+ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error {
553
+ repoDir := s.repoDirSnapshot()
554
+ if repoDir == "" {
555
+ return fmt.Errorf("git token store: repository path not configured")
556
+ }
557
+ repo, err := git.PlainOpen(repoDir)
558
+ if err != nil {
559
+ return fmt.Errorf("git token store: open repo: %w", err)
560
+ }
561
+ worktree, err := repo.Worktree()
562
+ if err != nil {
563
+ return fmt.Errorf("git token store: worktree: %w", err)
564
+ }
565
+ added := false
566
+ for _, rel := range relPaths {
567
+ if strings.TrimSpace(rel) == "" {
568
+ continue
569
+ }
570
+ if _, err = worktree.Add(rel); err != nil {
571
+ if errors.Is(err, os.ErrNotExist) {
572
+ if _, errRemove := worktree.Remove(rel); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
573
+ return fmt.Errorf("git token store: remove %s: %w", rel, errRemove)
574
+ }
575
+ } else {
576
+ return fmt.Errorf("git token store: add %s: %w", rel, err)
577
+ }
578
+ }
579
+ added = true
580
+ }
581
+ if !added {
582
+ return nil
583
+ }
584
+ status, err := worktree.Status()
585
+ if err != nil {
586
+ return fmt.Errorf("git token store: status: %w", err)
587
+ }
588
+ if status.IsClean() {
589
+ return nil
590
+ }
591
+ if strings.TrimSpace(message) == "" {
592
+ message = "Update auth store"
593
+ }
594
+ signature := &object.Signature{
595
+ Name: "CLIProxyAPI",
596
+ Email: "cliproxy@local",
597
+ When: time.Now(),
598
+ }
599
+ commitHash, err := worktree.Commit(message, &git.CommitOptions{
600
+ Author: signature,
601
+ })
602
+ if err != nil {
603
+ if errors.Is(err, git.ErrEmptyCommit) {
604
+ return nil
605
+ }
606
+ return fmt.Errorf("git token store: commit: %w", err)
607
+ }
608
+ headRef, errHead := repo.Head()
609
+ if errHead != nil {
610
+ if !errors.Is(errHead, plumbing.ErrReferenceNotFound) {
611
+ return fmt.Errorf("git token store: get head: %w", errHead)
612
+ }
613
+ } else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
614
+ return errRewrite
615
+ }
616
+ if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {
617
+ if errors.Is(err, git.NoErrAlreadyUpToDate) {
618
+ return nil
619
+ }
620
+ return fmt.Errorf("git token store: push: %w", err)
621
+ }
622
+ return nil
623
+ }
624
+
625
+ // rewriteHeadAsSingleCommit rewrites the current branch tip to a single-parentless commit and leaves history squashed.
626
+ func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch plumbing.ReferenceName, commitHash plumbing.Hash, message string, signature *object.Signature) error {
627
+ commitObj, err := repo.CommitObject(commitHash)
628
+ if err != nil {
629
+ return fmt.Errorf("git token store: inspect head commit: %w", err)
630
+ }
631
+ squashed := &object.Commit{
632
+ Author: *signature,
633
+ Committer: *signature,
634
+ Message: message,
635
+ TreeHash: commitObj.TreeHash,
636
+ ParentHashes: nil,
637
+ Encoding: commitObj.Encoding,
638
+ ExtraHeaders: commitObj.ExtraHeaders,
639
+ }
640
+ mem := &plumbing.MemoryObject{}
641
+ mem.SetType(plumbing.CommitObject)
642
+ if err := squashed.Encode(mem); err != nil {
643
+ return fmt.Errorf("git token store: encode squashed commit: %w", err)
644
+ }
645
+ newHash, err := repo.Storer.SetEncodedObject(mem)
646
+ if err != nil {
647
+ return fmt.Errorf("git token store: write squashed commit: %w", err)
648
+ }
649
+ if err := repo.Storer.SetReference(plumbing.NewHashReference(branch, newHash)); err != nil {
650
+ return fmt.Errorf("git token store: update branch reference: %w", err)
651
+ }
652
+ return nil
653
+ }
654
+
655
+ // CommitConfig commits and pushes configuration changes to git.
656
+ func (s *GitTokenStore) CommitConfig(_ context.Context) error {
657
+ if err := s.EnsureRepository(); err != nil {
658
+ return err
659
+ }
660
+ configPath := s.ConfigPath()
661
+ if configPath == "" {
662
+ return fmt.Errorf("git token store: config path not configured")
663
+ }
664
+ if _, err := os.Stat(configPath); err != nil {
665
+ if errors.Is(err, fs.ErrNotExist) {
666
+ return nil
667
+ }
668
+ return fmt.Errorf("git token store: stat config: %w", err)
669
+ }
670
+ s.mu.Lock()
671
+ defer s.mu.Unlock()
672
+ rel, err := s.relativeToRepo(configPath)
673
+ if err != nil {
674
+ return err
675
+ }
676
+ return s.commitAndPushLocked("Update config", rel)
677
+ }
678
+
679
+ func ensureEmptyFile(path string) error {
680
+ if _, err := os.Stat(path); err != nil {
681
+ if errors.Is(err, fs.ErrNotExist) {
682
+ return os.WriteFile(path, []byte{}, 0o600)
683
+ }
684
+ return err
685
+ }
686
+ return nil
687
+ }
688
+
689
+ func jsonEqual(a, b []byte) bool {
690
+ var objA any
691
+ var objB any
692
+ if err := json.Unmarshal(a, &objA); err != nil {
693
+ return false
694
+ }
695
+ if err := json.Unmarshal(b, &objB); err != nil {
696
+ return false
697
+ }
698
+ return deepEqualJSON(objA, objB)
699
+ }
700
+
701
+ func deepEqualJSON(a, b any) bool {
702
+ switch valA := a.(type) {
703
+ case map[string]any:
704
+ valB, ok := b.(map[string]any)
705
+ if !ok || len(valA) != len(valB) {
706
+ return false
707
+ }
708
+ for key, subA := range valA {
709
+ subB, ok1 := valB[key]
710
+ if !ok1 || !deepEqualJSON(subA, subB) {
711
+ return false
712
+ }
713
+ }
714
+ return true
715
+ case []any:
716
+ sliceB, ok := b.([]any)
717
+ if !ok || len(valA) != len(sliceB) {
718
+ return false
719
+ }
720
+ for i := range valA {
721
+ if !deepEqualJSON(valA[i], sliceB[i]) {
722
+ return false
723
+ }
724
+ }
725
+ return true
726
+ case float64:
727
+ valB, ok := b.(float64)
728
+ if !ok {
729
+ return false
730
+ }
731
+ return valA == valB
732
+ case string:
733
+ valB, ok := b.(string)
734
+ if !ok {
735
+ return false
736
+ }
737
+ return valA == valB
738
+ case bool:
739
+ valB, ok := b.(bool)
740
+ if !ok {
741
+ return false
742
+ }
743
+ return valA == valB
744
+ case nil:
745
+ return b == nil
746
+ default:
747
+ return false
748
+ }
749
+ }
internal/watcher/watcher.go CHANGED
@@ -29,11 +29,18 @@ import (
29
  // "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
30
 
31
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
 
32
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
33
  log "github.com/sirupsen/logrus"
34
  // "github.com/tidwall/gjson"
35
  )
36
 
 
 
 
 
 
 
37
  // Watcher manages file watching for configuration and authentication files
38
  type Watcher struct {
39
  configPath string
@@ -51,6 +58,7 @@ type Watcher struct {
51
  pendingUpdates map[string]AuthUpdate
52
  pendingOrder []string
53
  dispatchCancel context.CancelFunc
 
54
  }
55
 
56
  type stableIDGenerator struct {
@@ -114,7 +122,6 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config))
114
  if errNewWatcher != nil {
115
  return nil, errNewWatcher
116
  }
117
-
118
  w := &Watcher{
119
  configPath: configPath,
120
  authDir: authDir,
@@ -123,6 +130,12 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config))
123
  lastAuthHashes: make(map[string]string),
124
  }
125
  w.dispatchCond = sync.NewCond(&w.dispatchMu)
 
 
 
 
 
 
126
  return w, nil
127
  }
128
 
@@ -336,6 +349,41 @@ func (w *Watcher) stopDispatch() {
336
  w.clientsMutex.Unlock()
337
  }
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  func authEqual(a, b *coreauth.Auth) bool {
340
  return reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b))
341
  }
@@ -440,6 +488,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
440
  w.clientsMutex.Lock()
441
  w.lastConfigHash = finalHash
442
  w.clientsMutex.Unlock()
 
443
  }
444
  return
445
  }
@@ -691,6 +740,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
691
  log.Debugf("triggering server update callback after add/update")
692
  w.reloadCallback(cfg)
693
  }
 
694
  }
695
 
696
  // removeClient handles the removal of a single client.
@@ -708,6 +758,7 @@ func (w *Watcher) removeClient(path string) {
708
  log.Debugf("triggering server update callback after removal")
709
  w.reloadCallback(cfg)
710
  }
 
711
  }
712
 
713
  // SnapshotCombinedClients returns a snapshot of current combined clients.
 
29
  // "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
30
 
31
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
32
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
33
  coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
34
  log "github.com/sirupsen/logrus"
35
  // "github.com/tidwall/gjson"
36
  )
37
 
38
+ // gitCommitter captures the subset of git-backed token store capabilities used by the watcher.
39
+ type gitCommitter interface {
40
+ CommitConfig(ctx context.Context) error
41
+ CommitPaths(ctx context.Context, message string, paths ...string) error
42
+ }
43
+
44
  // Watcher manages file watching for configuration and authentication files
45
  type Watcher struct {
46
  configPath string
 
58
  pendingUpdates map[string]AuthUpdate
59
  pendingOrder []string
60
  dispatchCancel context.CancelFunc
61
+ gitCommitter gitCommitter
62
  }
63
 
64
  type stableIDGenerator struct {
 
122
  if errNewWatcher != nil {
123
  return nil, errNewWatcher
124
  }
 
125
  w := &Watcher{
126
  configPath: configPath,
127
  authDir: authDir,
 
130
  lastAuthHashes: make(map[string]string),
131
  }
132
  w.dispatchCond = sync.NewCond(&w.dispatchMu)
133
+ if store := sdkAuth.GetTokenStore(); store != nil {
134
+ if committer, ok := store.(gitCommitter); ok {
135
+ w.gitCommitter = committer
136
+ log.Debug("gitstore mode detected; watcher will commit changes to remote repository")
137
+ }
138
+ }
139
  return w, nil
140
  }
141
 
 
349
  w.clientsMutex.Unlock()
350
  }
351
 
352
+ func (w *Watcher) commitConfigAsync() {
353
+ if w == nil || w.gitCommitter == nil {
354
+ return
355
+ }
356
+ go func() {
357
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
358
+ defer cancel()
359
+ if err := w.gitCommitter.CommitConfig(ctx); err != nil {
360
+ log.Errorf("failed to commit config change: %v", err)
361
+ }
362
+ }()
363
+ }
364
+
365
+ func (w *Watcher) commitAuthAsync(message string, paths ...string) {
366
+ if w == nil || w.gitCommitter == nil {
367
+ return
368
+ }
369
+ filtered := make([]string, 0, len(paths))
370
+ for _, p := range paths {
371
+ if trimmed := strings.TrimSpace(p); trimmed != "" {
372
+ filtered = append(filtered, trimmed)
373
+ }
374
+ }
375
+ if len(filtered) == 0 {
376
+ return
377
+ }
378
+ go func() {
379
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
380
+ defer cancel()
381
+ if err := w.gitCommitter.CommitPaths(ctx, message, filtered...); err != nil {
382
+ log.Errorf("failed to commit auth changes: %v", err)
383
+ }
384
+ }()
385
+ }
386
+
387
  func authEqual(a, b *coreauth.Auth) bool {
388
  return reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b))
389
  }
 
488
  w.clientsMutex.Lock()
489
  w.lastConfigHash = finalHash
490
  w.clientsMutex.Unlock()
491
+ w.commitConfigAsync()
492
  }
493
  return
494
  }
 
740
  log.Debugf("triggering server update callback after add/update")
741
  w.reloadCallback(cfg)
742
  }
743
+ w.commitAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path)
744
  }
745
 
746
  // removeClient handles the removal of a single client.
 
758
  log.Debugf("triggering server update callback after removal")
759
  w.reloadCallback(cfg)
760
  }
761
+ w.commitAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
762
  }
763
 
764
  // SnapshotCombinedClients returns a snapshot of current combined clients.