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 +0 -3
- Dockerfile +2 -0
- cmd/server/main.go +89 -5
- docker-compose.yml +0 -1
- go.mod +20 -9
- go.sum +62 -15
- internal/api/handlers/management/handler.go +52 -21
- internal/api/server.go +50 -25
- internal/managementasset/updater.go +8 -0
- internal/misc/copy-example-config.go +40 -0
- internal/store/gitstore.go +749 -0
- internal/watcher/watcher.go +52 -1
|
@@ -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/*
|
|
@@ -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
|
|
@@ -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
|
| 108 |
-
// Otherwise, it defaults to "config.yaml" in the current working directory.
|
| 109 |
var configFilePath string
|
| 110 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -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
|
|
@@ -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 |
-
|
| 14 |
-
golang.org/x/
|
| 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/
|
| 34 |
-
github.com/klauspost/cpuid/v2 v2.
|
| 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.
|
| 46 |
-
golang.org/x/text v0.
|
| 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 |
)
|
|
@@ -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.
|
| 43 |
-
github.com/klauspost/cpuid/v2 v2.
|
| 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.
|
| 93 |
-
golang.org/x/crypto v0.
|
| 94 |
-
golang.org/x/net v0.
|
| 95 |
-
golang.org/x/net v0.
|
| 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.
|
| 102 |
-
golang.org/x/sys v0.
|
| 103 |
-
golang.org/x/
|
| 104 |
-
golang.org/x/
|
|
|
|
|
|
|
| 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=
|
|
@@ -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
|
| 29 |
-
configFilePath
|
| 30 |
-
mu
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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:
|
| 45 |
-
configFilePath:
|
| 46 |
-
failedAttempts:
|
| 47 |
-
authManager:
|
| 48 |
-
usageStats:
|
| 49 |
-
tokenStore:
|
|
|
|
|
|
|
| 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 !
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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:
|
| 200 |
-
handlers:
|
| 201 |
-
cfg:
|
| 202 |
-
accessManager:
|
| 203 |
-
requestLogger:
|
| 204 |
-
loggerToggle:
|
| 205 |
-
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
|
| 222 |
-
|
| 223 |
-
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
|
| 695 |
-
case prevSecretEmpty && !newSecretEmpty:
|
| 696 |
s.registerManagementRoutes()
|
| 697 |
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
| 698 |
-
log.Info("management routes enabled
|
| 699 |
} else {
|
| 700 |
s.managementRoutesEnabled.Store(true)
|
| 701 |
}
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
s.managementRoutesEnabled.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 {
|
|
@@ -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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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.
|