├── .dockerignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── gitdir │ ├── cmd_hook.go │ ├── cmd_serve.go │ ├── config.go │ └── main.go ├── config.go ├── config_admin.go ├── config_ensure.go ├── config_operations.go ├── config_org.go ├── config_user.go ├── config_validate.go ├── context.go ├── context_test.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── hooks.go ├── internal ├── git │ ├── operations.go │ ├── repository.go │ └── utils.go └── yaml │ ├── encode.go │ ├── node.go │ ├── operations.go │ └── utils.go ├── models ├── admin_config.go ├── org_config.go ├── private_key.go ├── public_key.go ├── repo_config.go └── user_config.go ├── repo.go ├── repo_perms.go ├── repo_test.go ├── ssh_commands.go ├── ssh_server.go ├── user.go ├── user_test.go ├── utils.go └── utils_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/.DS_Store 3 | **/node_modules 4 | Dockerfile 5 | docker-compose.yaml 6 | .env 7 | gitdir 8 | tmp 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.19 18 | - name: Build 19 | run: go build -v ./... 20 | - name: Test 21 | run: go test -v ./... 22 | 23 | lint: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: golangci-lint 28 | uses: golangci/golangci-lint-action@v2 29 | with: 30 | version: v1.49.0 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Go 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | /tmp 16 | admin-clone 17 | .env 18 | /gitdir 19 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - exhaustive 5 | - exhaustivestruct 6 | - exhaustruct 7 | - gochecknoglobals 8 | - godox 9 | - goerr113 10 | - gomnd 11 | - ireturn 12 | - nestif 13 | - nlreturn 14 | - testpackage 15 | - unparam 16 | - varnamelen 17 | - wrapcheck 18 | 19 | # Deprecated linters 20 | - deadcode 21 | - golint 22 | - ifshort 23 | - interfacer 24 | - maligned 25 | - nosnakecase 26 | - scopelint 27 | - structcheck 28 | - varcheck 29 | 30 | linters-settings: 31 | govet: 32 | check-shadowing: true 33 | gci: 34 | local-prefixes: github.com/belak/go-gitdir 35 | tagliatelle: 36 | case: 37 | rules: 38 | yaml: snake 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the application 2 | FROM golang:1.19-bullseye as builder 3 | 4 | RUN mkdir /build && mkdir /usr/local/src/gitdir 5 | WORKDIR /usr/local/src/gitdir 6 | 7 | ADD ./go.mod ./go.sum ./ 8 | RUN go mod download 9 | ADD . ./ 10 | 11 | RUN go build -v -o /build/gitdir ./cmd/gitdir 12 | 13 | # State 2: Copy files and configure what we need 14 | FROM debian:bullseye-slim as runner 15 | 16 | ENV GITDIR_BASE_DIR=/var/lib/gitdir \ 17 | GITDIR_BIND_ADDR=0.0.0.0:2222 18 | 19 | VOLUME /var/lib/gitdir 20 | 21 | # Install git so git-upload-pack and git-receive-pack are available. 22 | RUN apt-get update && apt-get install -y git \ 23 | && rm -rf /var/lib/apt/lists/* 24 | 25 | COPY --from=builder /build/gitdir /usr/bin/gitdir 26 | 27 | EXPOSE 2222 28 | CMD ["gitdir"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Kaleb Elwert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice (including the next 13 | paragraph) shall be included in all copies or substantial portions of the 14 | Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 19 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 21 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-gitdir 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/belak/go-gitdir)](https://goreportcard.com/report/github.com/belak/go-gitdir) 4 | [![Build Status](https://github.com/belak/gitdir/actions/workflows/test.yml/badge.svg)](https://github.com/belak/gitdir/actions/workflows/test.yml) 5 | 6 | This project makes it incredibly easy to host a secure git server with a config 7 | that can be easily rolled back. 8 | 9 | It aims to solve a number of problems other git servers have: 10 | 11 | - Requires no external dependencies other than the binary and git 12 | - Stores its configuration in a repo managed by itself 13 | - Doesn't hook into the system's user accounts 14 | - No vendor lock-in - everything is just a bare git repository 15 | 16 | ## Origins 17 | 18 | The main goal of this project is to enable simple git hosting when a full 19 | solution like Bitbucket, Github, Gitlab, Gitea, etc is not needed. 20 | 21 | This project was inspired by gitolite and gitosis, but also includes a built-in 22 | ssh server and some additional flexability. It is not considered stable, but 23 | should be usable enough to experiment with. 24 | 25 | Thankfully because all the repos are simply stored as bare git repositories, it 26 | should be fairly simple to migrate to or from other git hosting solutions. There 27 | is no vendor lock-in. 28 | 29 | ## Requirements 30 | 31 | Build requirements: 32 | 33 | - Go >= 1.13 34 | 35 | Runtime requirements: 36 | 37 | - git (for git-receive-pack and git-upload-pack) 38 | 39 | ## Building 40 | 41 | Clone the repository somewhere, outside the GOPATH. Then, from the root of the 42 | source tree, run: 43 | 44 | ``` 45 | go build ./cmd/gitdir 46 | ``` 47 | 48 | This will create a binary called `gitdir`. 49 | 50 | ## Running 51 | 52 | ### Server Config 53 | 54 | There are a number of environment variables which can be used to configure your 55 | go-git-dir instance. 56 | 57 | The following are required: 58 | 59 | - `GITDIR_BASE_DIR` - A directory to store all repositories in. This folder must 60 | exist when the service starts up. 61 | 62 | The following are optional: 63 | 64 | - `GITDIR_BIND_ADDR` - The address and port to bind the service to. This 65 | defaults to `:2222`. 66 | - `GITDIR_LOG_READABLE` - A true value if the log should be human readable 67 | - `GITDIR_LOG_DEBUG` - A true value if debug logging should be enabled 68 | - `GITDIR_ADMIN_USER` - The name of an admin user which the server will ensure 69 | exists on startup. 70 | - `GITHUB_ADMIN_PUBLIC_KEY` - The contents of a public key which will be added 71 | to the admin user on startup. 72 | 73 | ### Runtime Config 74 | 75 | The runtime config is stored in the "admin" repository. It can be cloned and 76 | modified by any admin on the server. In it you can specify groups (groupings of 77 | users for config or convenience reasons), repos, and orgs (groupings of repos 78 | managed by a person). 79 | 80 | Additionally, there are a number of options that can be specified in this file 81 | which change the behavior of the server. 82 | 83 | - `implicit_repos` - allows a user with admin access to that area to create 84 | repos by simply pushing to them. 85 | - `user_config_keys` - allows users to specify ssh keys in their own config, 86 | rather than relying on the main admin config. 87 | - `user_config_repos` - allows users to specify repos in their own config, 88 | rather than relying on the main admin config. 89 | - `org_config_repos` - allows org admins to specify repos in their own config, 90 | rather than relying on the main admin config. 91 | 92 | ## Usage 93 | 94 | Simply run the built binary with `GITDIR_BASE_DIR` set and start using it! 95 | 96 | On first run, gitdir will push a commit to the admin repo with a sample 97 | config as well as generated server ssh keys. These can be updated at any time 98 | (even at runtime) but if the server restarts and the keys cannot be loaded, they 99 | will be re-generated. 100 | 101 | If you set `GITDIR_ADMIN_USER` and `GITHUB_ADMIN_PUBLIC_KEY` an admin user will 102 | automatically be added to the config. 103 | 104 | If you do not set those environment variables, you will need to manually clone 105 | the admin repository (at `$GITDIR_BASE_DIR/admin/admin`) to add a user to 106 | `config.yml` and set them as an admin. 107 | 108 | ## Sample Config 109 | 110 | Sample admin `config.yml`: 111 | 112 | ``` 113 | users: 114 | belak: 115 | is_admin: true 116 | keys: 117 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeQfBUWIqpGXS8xCOg/0RKVOGTnzpIdL7r9wK1/xA52 belak@tmp 118 | repos: 119 | personal-gitdir: {} 120 | 121 | groups: 122 | admins: 123 | - belak 124 | 125 | repos: 126 | go-gitdir: 127 | public: true 128 | 129 | write: 130 | - $admins 131 | read: 132 | - some-other-user 133 | 134 | orgs: 135 | vault: 136 | admins: 137 | - $admins 138 | write: 139 | - some-org-user 140 | read: 141 | - some-other-org-user 142 | 143 | repos: 144 | the-vault: 145 | write: 146 | - some-repo-access-user 147 | 148 | options: 149 | implicit_repos: false 150 | user_config_keys: true 151 | user_config_repos: false 152 | org_config_repos: false 153 | ``` 154 | 155 | ## Repo Creation 156 | 157 | All repos defined in the config are created when the config is loaded. At 158 | runtime, if implicit repos are enabled, trying to access a repo where you have 159 | admin access will implicitly create it. 160 | -------------------------------------------------------------------------------- /cmd/gitdir/cmd_hook.go: -------------------------------------------------------------------------------- 1 | //nolint:forbidigo 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/rs/zerolog/log" 9 | 10 | "github.com/belak/go-gitdir" 11 | "github.com/belak/go-gitdir/models" 12 | ) 13 | 14 | func cmdHook(c Config) { 15 | log.Info().Msg("starting hook") 16 | 17 | if len(os.Args) < 3 { 18 | log.Fatal().Msg("missing hook name") 19 | } 20 | 21 | path, ok := os.LookupEnv("GITDIR_HOOK_REPO_PATH") 22 | if !ok { 23 | log.Fatal().Msg("missing repo path") 24 | } 25 | 26 | pkData, ok := os.LookupEnv("GITDIR_HOOK_PUBLIC_KEY") 27 | if !ok { 28 | log.Fatal().Msg("missing public key") 29 | } 30 | 31 | pk, err := models.ParsePublicKey([]byte(pkData)) 32 | if err != nil { 33 | log.Fatal().Err(err).Msg("failed to parse public key") 34 | } 35 | 36 | config := gitdir.NewConfig(c.FS()) 37 | 38 | err = config.Load() 39 | if err != nil { 40 | log.Fatal().Err(err).Msg("failed to load gitdir") 41 | } 42 | 43 | // Call the actual hook 44 | err = config.RunHook(os.Args[2], path, pk, os.Args[3:], os.Stdin) 45 | if err != nil { 46 | fmt.Println(err) 47 | os.Exit(1) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/gitdir/cmd_serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | 6 | "github.com/belak/go-gitdir" 7 | ) 8 | 9 | func cmdServe(c Config) { 10 | log.Info().Msg("starting server") 11 | 12 | serv, err := gitdir.NewServer(c.FS()) 13 | if err != nil { 14 | log.Fatal().Err(err).Msg("failed to load SSH server") 15 | } 16 | 17 | // If both the AdminUser and AdminPublicKey were set, attempt to add that 18 | // user to the config. 19 | if c.AdminUser != "" && c.AdminPublicKey != nil { 20 | log.Info().Str("user", c.AdminUser).Msg("ensuring admin user exists") 21 | 22 | err = serv.EnsureAdminUser(c.AdminUser, c.AdminPublicKey) 23 | if err != nil { 24 | log.Fatal().Err(err).Msg("failed to add admin user") 25 | } 26 | } 27 | 28 | serv.Addr = c.BindAddr 29 | 30 | err = serv.ListenAndServe() 31 | if err != nil { 32 | log.Fatal().Err(err).Msg("failed to run SSH server") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/gitdir/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | 10 | billy "github.com/go-git/go-billy/v5" 11 | "github.com/go-git/go-billy/v5/osfs" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/log" 14 | 15 | "github.com/belak/go-gitdir/models" 16 | ) 17 | 18 | // Config stores all the server-level settings. These cannot be changed at 19 | // runtime. They are only used by the binary and are passed to the proper 20 | // places. 21 | type Config struct { 22 | BindAddr string 23 | BasePath string 24 | LogFormat string 25 | LogDebug bool 26 | AdminUser string 27 | AdminPublicKey *models.PublicKey 28 | } 29 | 30 | // FS returns the billy.Filesystem for this base path. 31 | func (c Config) FS() billy.Filesystem { 32 | return osfs.New(c.BasePath) 33 | } 34 | 35 | // DefaultConfig is used as the base config. 36 | var DefaultConfig = Config{ 37 | BindAddr: ":2222", 38 | BasePath: "./tmp", 39 | LogFormat: "json", 40 | LogDebug: false, 41 | AdminUser: "", 42 | AdminPublicKey: nil, 43 | } 44 | 45 | // NewEnvConfig returns a new Config based on environment variables. 46 | func NewEnvConfig() (Config, error) { //nolint:cyclop,funlen 47 | var err error 48 | 49 | c := DefaultConfig 50 | 51 | if rawDebug, ok := os.LookupEnv("GITDIR_DEBUG"); ok { 52 | c.LogDebug, err = strconv.ParseBool(rawDebug) 53 | if err != nil { 54 | return c, fmt.Errorf("GITDIR_DEBUG: %w", err) 55 | } 56 | } 57 | 58 | if logFormat, ok := os.LookupEnv("GITDIR_LOG_FORMAT"); ok { 59 | if logFormat != "console" && logFormat != "json" { 60 | return c, errors.New("GITDIR_LOG_FORMAT: must be console or json") 61 | } 62 | } 63 | 64 | // Set up the logger - anything other than console defaults to json. 65 | if c.LogFormat == "console" { 66 | log.Logger = zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger() 67 | } 68 | 69 | if c.LogDebug { 70 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 71 | } 72 | 73 | if bindAddr, ok := os.LookupEnv("GITDIR_BIND_ADDR"); ok { 74 | c.BindAddr = bindAddr 75 | } 76 | 77 | var ok bool 78 | 79 | if c.BasePath, ok = os.LookupEnv("GITDIR_BASE_DIR"); !ok { 80 | return c, fmt.Errorf("GITDIR_BASE_DIR: not set") 81 | } 82 | 83 | if c.BasePath, err = filepath.Abs(c.BasePath); err != nil { 84 | return c, fmt.Errorf("GITDIR_BASE_DIR: %w", err) 85 | } 86 | 87 | info, err := os.Stat(c.BasePath) 88 | if err != nil { 89 | return c, fmt.Errorf("GITDIR_BASE_DIR: %w", err) 90 | } 91 | 92 | if !info.IsDir() { 93 | return c, errors.New("GITDIR_BASE_DIR: not a directory") 94 | } 95 | 96 | // AdminUser and AdminPublicKey are allowed to not be set. 97 | if adminUser, ok := os.LookupEnv("GITDIR_ADMIN_USER"); ok { 98 | c.AdminUser = adminUser 99 | } 100 | 101 | if adminPublicKeyRaw, ok := os.LookupEnv("GITDIR_ADMIN_PUBLIC_KEY"); ok { 102 | adminPublicKey, err := models.ParsePublicKey([]byte(adminPublicKeyRaw)) 103 | if err != nil { 104 | return c, fmt.Errorf("GITDIR_ADMIN_PUBLIC_KEY: %w", err) 105 | } 106 | 107 | c.AdminPublicKey = adminPublicKey 108 | } 109 | 110 | return c, nil 111 | } 112 | -------------------------------------------------------------------------------- /cmd/gitdir/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/joho/godotenv" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func main() { 11 | _ = godotenv.Load() 12 | 13 | c, err := NewEnvConfig() 14 | if err != nil { 15 | log.Fatal().Err(err).Msg("failed to load base config") 16 | } 17 | 18 | if len(os.Args) > 1 { 19 | switch os.Args[1] { 20 | case "hook": 21 | cmdHook(c) 22 | default: 23 | log.Fatal().Msg("sub-command not found") 24 | } 25 | 26 | return 27 | } 28 | 29 | log.Info().Msg("starting go-gitdir") 30 | 31 | cmdServe(c) 32 | } 33 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "fmt" 5 | 6 | billy "github.com/go-git/go-billy/v5" 7 | 8 | "github.com/belak/go-gitdir/internal/git" 9 | "github.com/belak/go-gitdir/models" 10 | ) 11 | 12 | // Config represents the config which has been loaded from all repos. 13 | type Config struct { 14 | Invites map[string]string 15 | Groups map[string][]string 16 | Orgs map[string]*models.OrgConfig 17 | Users map[string]*models.AdminConfigUser 18 | Repos map[string]*models.RepoConfig 19 | Options models.AdminConfigOptions 20 | PrivateKeys []models.PrivateKey 21 | 22 | // Internal state 23 | fs billy.Filesystem 24 | publicKeys map[string]string `yaml:"-"` 25 | 26 | // We store any override hashes for repos so this can be used for hooks as 27 | // well. 28 | adminRepoHash string 29 | orgRepos map[string]string 30 | userRepos map[string]string 31 | } 32 | 33 | // NewConfig returns an empty config, attached to the given fs. In general, Load 34 | // should be called after creating a new config at a bare minimum. 35 | func NewConfig(fs billy.Filesystem) *Config { 36 | return &Config{ 37 | Invites: make(map[string]string), 38 | Groups: make(map[string][]string), 39 | Orgs: make(map[string]*models.OrgConfig), 40 | Users: make(map[string]*models.AdminConfigUser), 41 | Repos: make(map[string]*models.RepoConfig), 42 | 43 | orgRepos: make(map[string]string), 44 | userRepos: make(map[string]string), 45 | publicKeys: make(map[string]string), 46 | 47 | Options: models.DefaultAdminConfigOptions, 48 | 49 | fs: fs, 50 | } 51 | } 52 | 53 | // Load will load the config from the given fs. 54 | func (c *Config) Load() error { 55 | adminRepo, err := c.openAdminRepo() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return c.loadConfig(adminRepo) 61 | } 62 | 63 | func (c *Config) openAdminRepo() (*git.Repository, error) { 64 | adminRepo, err := git.EnsureRepo(c.fs, "admin/admin") 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | err = adminRepo.Checkout(c.adminRepoHash) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return adminRepo, nil 75 | } 76 | 77 | func (c *Config) loadConfig(adminRepo *git.Repository) error { 78 | // Load config 79 | err := c.loadAdminConfig(adminRepo) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | // Load sub-configs 85 | err = newMultiError( 86 | c.loadUserConfigs(), 87 | c.loadOrgConfigs(), 88 | ) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | c.flatten() 94 | 95 | return nil 96 | } 97 | 98 | func (c *Config) EnsureConfig() error { 99 | adminRepo, err := c.openAdminRepo() 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return c.ensureConfig(adminRepo) 105 | } 106 | 107 | func (c *Config) ensureConfig(adminRepo *git.Repository) error { 108 | // Ensure config 109 | err := c.ensureAdminConfig(adminRepo) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | // Load config 115 | err = c.loadConfig(adminRepo) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | // We only commit at the very end, after everything has been loaded. This 121 | // ensures we have a valid config. 122 | status, err := adminRepo.Worktree.Status() 123 | if err != nil { 124 | return err 125 | } 126 | 127 | if !status.IsClean() { 128 | err = adminRepo.Commit("Updated config", nil) 129 | if err != nil { 130 | return err 131 | } 132 | } 133 | 134 | return nil 135 | } 136 | 137 | // EnsureUser will load the current admin config and ensure the given user 138 | // exists. 139 | func (c *Config) EnsureAdminUser(username string, pubKey *models.PublicKey) error { 140 | adminRepo, err := c.openAdminRepo() 141 | if err != nil { 142 | return err 143 | } 144 | 145 | // Ensure the base config before we try and add a user. 146 | err = c.ensureAdminConfig(adminRepo) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | // Ensure User 152 | err = c.ensureAdminUser(adminRepo, username, pubKey) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | // Load config 158 | err = c.loadConfig(adminRepo) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | // We only commit at the very end, after everything has been loaded. This 164 | // ensures we have a valid config. 165 | status, err := adminRepo.Worktree.Status() 166 | if err != nil { 167 | return err 168 | } 169 | 170 | if !status.IsClean() { 171 | err = adminRepo.Commit(fmt.Sprintf("Added %s to config as admin", username), nil) 172 | if err != nil { 173 | return err 174 | } 175 | } 176 | 177 | return nil 178 | } 179 | 180 | func (c *Config) flatten() { 181 | // Add all user public keys to the config. 182 | for username, user := range c.Users { 183 | for _, key := range user.Keys { 184 | c.publicKeys[key.RawMarshalAuthorizedKey()] = username 185 | } 186 | } 187 | } 188 | 189 | // SetHash will set the hash of the admin repo to use when loading. 190 | func (c *Config) SetHash(hash string) error { 191 | adminRepo, err := git.EnsureRepo(c.fs, "admin/admin") 192 | if err != nil { 193 | return err 194 | } 195 | 196 | err = adminRepo.Checkout(hash) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | c.adminRepoHash = hash 202 | 203 | return nil 204 | } 205 | 206 | // SetUserHash will set the hash of the given user repo to use when loading. 207 | func (c *Config) SetUserHash(username, hash string) error { 208 | repo, err := git.EnsureRepo(c.fs, "admin/user-"+username) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | err = repo.Checkout(hash) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | c.userRepos[username] = hash 219 | 220 | return nil 221 | } 222 | 223 | // SetOrgHash will set the hash of the given org repo to use when loading. 224 | func (c *Config) SetOrgHash(orgName, hash string) error { 225 | repo, err := git.EnsureRepo(c.fs, "admin/org-"+orgName) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | err = repo.Checkout(hash) 231 | if err != nil { 232 | return err 233 | } 234 | 235 | c.orgRepos[orgName] = hash 236 | 237 | return nil 238 | } 239 | -------------------------------------------------------------------------------- /config_admin.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "github.com/belak/go-gitdir/internal/git" 5 | "github.com/belak/go-gitdir/models" 6 | ) 7 | 8 | func (c *Config) ensureAdminConfig(repo *git.Repository) error { 9 | return newMultiError( 10 | c.ensureAdminConfigYaml(repo), 11 | c.ensureAdminEd25519Key(repo), 12 | c.ensureAdminRSAKey(repo), 13 | ) 14 | } 15 | 16 | func (c *Config) ensureAdminConfigYaml(repo *git.Repository) error { 17 | return repo.UpdateFile("config.yml", ensureSampleConfig) 18 | } 19 | 20 | func (c *Config) ensureAdminUser(repo *git.Repository, user string, pubKey *models.PublicKey) error { 21 | return repo.UpdateFile("config.yml", func(data []byte) ([]byte, error) { 22 | return ensureAdminUser(data, user, pubKey.MarshalAuthorizedKey()) 23 | }) 24 | } 25 | 26 | func (c *Config) ensureAdminEd25519Key(repo *git.Repository) error { 27 | return repo.UpdateFile("ssh/id_ed25519", func(data []byte) ([]byte, error) { 28 | if data != nil { 29 | return data, nil 30 | } 31 | 32 | pk, err := models.GenerateEd25519PrivateKey() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return pk.MarshalPrivateKey() 38 | }) 39 | } 40 | 41 | func (c *Config) ensureAdminRSAKey(repo *git.Repository) error { 42 | return repo.UpdateFile("ssh/id_rsa", func(data []byte) ([]byte, error) { 43 | if data != nil { 44 | return data, nil 45 | } 46 | 47 | pk, err := models.GenerateRSAPrivateKey() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return pk.MarshalPrivateKey() 53 | }) 54 | } 55 | 56 | func (c *Config) loadAdminConfig(adminRepo *git.Repository) error { 57 | configData, err := adminRepo.GetFile("config.yml") 58 | if err != nil { 59 | return err 60 | } 61 | 62 | adminConfig, err := models.ParseAdminConfig(configData) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // Merge the adminConfig with the base config. Note that this will reset all 68 | // values. 69 | c.Invites = adminConfig.Invites 70 | c.Groups = adminConfig.Groups 71 | c.Orgs = adminConfig.Orgs 72 | c.Users = adminConfig.Users 73 | c.Repos = adminConfig.Repos 74 | c.Options = adminConfig.Options 75 | 76 | // Load the private keys 77 | c.PrivateKeys = nil 78 | 79 | keyData, err := adminRepo.GetFile("ssh/id_ed25519") 80 | if err != nil { 81 | return err 82 | } 83 | 84 | pk, err := models.ParseEd25519PrivateKey(keyData) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | c.PrivateKeys = append(c.PrivateKeys, pk) 90 | 91 | keyData, err = adminRepo.GetFile("ssh/id_rsa") 92 | if err != nil { 93 | return err 94 | } 95 | 96 | pk, err = models.ParseRSAPrivateKey(keyData) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | c.PrivateKeys = append(c.PrivateKeys, pk) 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /config_ensure.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "github.com/belak/go-gitdir/internal/yaml" 5 | "github.com/belak/go-gitdir/models" 6 | ) 7 | 8 | func ensureSampleConfig(data []byte) ([]byte, error) { 9 | rootNode, modified, err := ensureSampleConfigYaml(data) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | if !modified { 15 | return data, nil 16 | } 17 | 18 | return rootNode.Encode() 19 | } 20 | 21 | func ensureSampleConfigYaml(data []byte) (*yaml.Node, bool, error) { 22 | rootNode, targetNode, err := yaml.EnsureDocument(data) 23 | if err != nil { 24 | return nil, false, err 25 | } 26 | 27 | vals := [5]bool{ 28 | ensureSampleInvites(targetNode), 29 | ensureSampleUsers(targetNode), 30 | ensureSampleGroups(targetNode), 31 | ensureSampleOrgs(targetNode), 32 | ensureSampleOptions(targetNode), 33 | } 34 | 35 | // If we had to make any of the modifications, we need to specify the node 36 | // was updated. 37 | return rootNode, vals[0] || vals[1] || vals[2] || vals[3], nil 38 | } 39 | 40 | func ensureSampleInvites(targetNode *yaml.Node) bool { 41 | _, modified := targetNode.EnsureKey( 42 | "invites", 43 | yaml.NewMappingNode(), 44 | &yaml.EnsureOptions{ 45 | Comment: ` 46 | Invites define temporary codes for a user to get in to the service. They 47 | can SSH in using ssh invite:invite-code@go-code and it will add that public 48 | key to their user. 49 | # 50 | Sample invites: 51 | # 52 | invites: 53 | orai7Quaipoocungah1vee6Ieh8Ien: belak`, 54 | }, 55 | ) 56 | 57 | return modified 58 | } 59 | 60 | func ensureSampleUsers(targetNode *yaml.Node) bool { 61 | _, modified := targetNode.EnsureKey( 62 | "users", 63 | yaml.NewMappingNode(), 64 | &yaml.EnsureOptions{ 65 | Comment: ` 66 | Users defines the users who have access to the service. They will need an 67 | SSH key or invite added to their user account before they can access the 68 | server. 69 | # 70 | Sample user (with all options set): 71 | # 72 | belak: 73 | is_admin: true 74 | disabled: false 75 | keys: 76 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDeQfBUWIqpGXS8xCOg/0RKVOGTnzpIdL7r9wK1/xA52 belak@tmp`, 77 | }, 78 | ) 79 | 80 | return modified 81 | } 82 | 83 | func ensureSampleGroups(targetNode *yaml.Node) bool { 84 | _, modified := targetNode.EnsureKey( 85 | "groups", 86 | yaml.NewMappingNode(), 87 | &yaml.EnsureOptions{ 88 | Comment: ` 89 | Groups can be used in any place a normal user could be used. They are prefixed 90 | with a $, so the admins group could be accessed with $admins. Groups can be 91 | defined recursively, but they cannot have loops. 92 | # 93 | Sample groups: 94 | # 95 | groups: 96 | admins: 97 | - belak 98 | - some-trusted-user 99 | vault-members: 100 | - $admins 101 | - some-less-trusted-user`, 102 | }, 103 | ) 104 | 105 | return modified 106 | } 107 | 108 | func ensureSampleOrgs(targetNode *yaml.Node) bool { 109 | _, modified := targetNode.EnsureKey( 110 | "orgs", 111 | yaml.NewMappingNode(), 112 | &yaml.EnsureOptions{ 113 | Comment: ` 114 | Org repos are accessible at @org-name/repo. Note that if admins is not 115 | specified, the only users with admin access will be global admins. By 116 | default, all members of an org will have read-write access to repos. This 117 | can be changed with the read and write keys. 118 | # 119 | Sample org (with all options set): 120 | # 121 | vault: 122 | admins: 123 | - belak 124 | write: 125 | - some user 126 | read: 127 | - some-other-user 128 | repos: 129 | project-name-here: 130 | public: false 131 | write: 132 | - belak 133 | read: 134 | - some-user 135 | - some-other-user`, 136 | }, 137 | ) 138 | 139 | return modified 140 | } 141 | 142 | // NOTE: this would make more sense as a map, but we want to keep the order. 143 | var sampleOptions = []struct { 144 | Name string 145 | Comment string 146 | Tag yaml.ScalarTag 147 | Value string 148 | }{ 149 | { 150 | Name: "git_user", 151 | Comment: "which username to use as the global git user", 152 | Value: models.DefaultAdminConfigOptions.GitUser, 153 | }, 154 | { 155 | Name: "org_prefix", 156 | Comment: "the prefix to use when cloning org repos", 157 | Value: models.DefaultAdminConfigOptions.OrgPrefix, 158 | }, 159 | { 160 | Name: "user_prefix", 161 | Comment: "the prefix to use when cloning user repos", 162 | Value: models.DefaultAdminConfigOptions.UserPrefix, 163 | }, 164 | { 165 | Name: "invite_prefix", 166 | Comment: "the prefix to use when sshing in with an invite", 167 | Value: models.DefaultAdminConfigOptions.InvitePrefix, 168 | }, 169 | { 170 | Name: "implicit_repos", 171 | Comment: `allow users with admin access to a given area to create repos by simply 172 | pushing to them.`, 173 | Tag: "!!bool", 174 | Value: "false", 175 | }, 176 | { 177 | Name: "user_config_keys", 178 | Comment: `allows users to specify ssh keys in their own config, rather than relying 179 | on the main admin config.`, 180 | Tag: "!!bool", 181 | Value: "false", 182 | }, 183 | { 184 | Name: "user_config_repos", 185 | Comment: `allows users to specify repos in their own config, rather than relying on 186 | the main admin config.`, 187 | Tag: "!!bool", 188 | Value: "false", 189 | }, 190 | { 191 | Name: "org_config_repos", 192 | Comment: `allows org admins to specify repos in their own config, rather than 193 | relying on the main admin config.`, 194 | Tag: "!!bool", 195 | Value: "false", 196 | }, 197 | } 198 | 199 | func ensureSampleOptions(targetNode *yaml.Node) bool { 200 | optionsVal, modified := targetNode.EnsureKey( 201 | "options", 202 | yaml.NewMappingNode(), 203 | nil, 204 | ) 205 | 206 | // Ensure all our options are on the options struct. 207 | for _, opt := range sampleOptions { 208 | _, added := optionsVal.EnsureKey( 209 | opt.Name, 210 | yaml.NewScalarNode(opt.Value, opt.Tag), 211 | &yaml.EnsureOptions{Comment: opt.Comment}, 212 | ) 213 | 214 | modified = modified || added 215 | } 216 | 217 | return modified 218 | } 219 | 220 | func ensureAdminUser(data []byte, username, pubKey string) ([]byte, error) { 221 | rootNode, modified, err := ensureAdminUserYaml(data, username, pubKey) 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | if !modified { 227 | return data, nil 228 | } 229 | 230 | return rootNode.Encode() 231 | } 232 | 233 | func ensureAdminUserYaml(data []byte, username, pubKey string) (*yaml.Node, bool, error) { 234 | rootNode, targetNode, err := yaml.EnsureDocument(data) 235 | if err != nil { 236 | return nil, false, err 237 | } 238 | 239 | usersNode, _ := targetNode.EnsureKey("users", yaml.NewMappingNode(), nil) 240 | userNode, _ := usersNode.EnsureKey(username, yaml.NewMappingNode(), nil) 241 | keysNode, _ := userNode.EnsureKey("keys", yaml.NewSequenceNode(), nil) 242 | modified := keysNode.AppendUniqueScalar(yaml.NewScalarNode(pubKey, "")) 243 | 244 | // Ensure this user is an admin and isn't disabled 245 | _, adminModified := userNode.EnsureKey("is_admin", yaml.NewScalarNode("true", yaml.ScalarTagBool), nil) 246 | disabledModified := userNode.RemoveKey("disabled") 247 | 248 | return rootNode, modified || disabledModified || adminModified, nil 249 | } 250 | -------------------------------------------------------------------------------- /config_operations.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | /* 4 | // AcceptInvite attempts to accept an invite and set up the given user. 5 | func (serv *Server) AcceptInvite(invite string, key PublicKey) bool { //nolint:funlen 6 | serv.lock.Lock() 7 | defer serv.lock.Unlock() 8 | 9 | // It's expensive to lock, so we need to do the fast stuff first and bail as 10 | // early as possible if it's not valid. 11 | 12 | // Step 1: Look up the invite 13 | username, ok := serv.settings.Invites[invite] 14 | if !ok { 15 | return false 16 | } 17 | 18 | fmt.Println("found user") 19 | 20 | adminRepo, err := EnsureRepo("admin/admin", true) 21 | if err != nil { 22 | log.Warn().Err(err).Msg("Admin repo doesn't exist") 23 | return false 24 | } 25 | 26 | err = adminRepo.UpdateFile("config.yml", func(data []byte) ([]byte, error) { 27 | rootNode, _, err := ensureSampleConfigYaml(data) //nolint:govet 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // We can assume the config file is in a valid format because of 33 | // ensureSampleConfig 34 | targetNode := rootNode.Content[0] 35 | 36 | // Step 2: Ensure the user exists and is not disabled. 37 | usersVal := yamlLookupVal(targetNode, "users") 38 | userVal, _ := yamlEnsureKey(usersVal, username, &yaml.Node{Kind: yaml.MappingNode}, "", false) 39 | _ = yamlRemoveKey(userVal, "disabled") 40 | 41 | // Step 3: Add the key to the user 42 | keysVal, _ := yamlEnsureKey(userVal, "keys", &yaml.Node{Kind: yaml.SequenceNode}, "", false) 43 | keysVal.Content = append(keysVal.Content, &yaml.Node{ 44 | Kind: yaml.ScalarNode, 45 | Value: key.MarshalAuthorizedKey(), 46 | }) 47 | 48 | // Step 4: Remove the invite (and any others this user owns) 49 | var staleInvites []string 50 | invitesVal := yamlLookupVal(targetNode, "invites") 51 | 52 | for i := 0; i+1 < len(invitesVal.Content); i += 2 { 53 | if invitesVal.Content[i+1].Value == username { 54 | staleInvites = append(staleInvites, invitesVal.Content[i].Value) 55 | } 56 | } 57 | 58 | for _, val := range staleInvites { 59 | yamlRemoveKey(invitesVal, val) 60 | } 61 | 62 | // Step 5: Re-encode back to yaml 63 | data, err = yamlEncode(rootNode) 64 | return data, err 65 | }) 66 | if err != nil { 67 | log.Warn().Err(err).Msg("Failed to update config") 68 | return false 69 | } 70 | 71 | err = adminRepo.Commit("Added "+username+" from invite "+invite, nil) 72 | if err != nil { 73 | return false 74 | } 75 | 76 | err = serv.reloadInternal() 77 | 78 | // The invite was successfully accepted if the server reloaded properly. 79 | return err == nil 80 | } 81 | */ 82 | -------------------------------------------------------------------------------- /config_org.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "github.com/belak/go-gitdir/internal/git" 5 | "github.com/belak/go-gitdir/models" 6 | ) 7 | 8 | func (c *Config) loadOrgConfigs() error { 9 | // Bail early if we don't need to load anything. 10 | if !c.Options.OrgConfig { 11 | return nil 12 | } 13 | 14 | errors := make([]error, 0, len(c.Orgs)) 15 | 16 | for orgName := range c.Orgs { 17 | errors = append(errors, c.loadOrgConfig(orgName)) 18 | } 19 | 20 | // Because we want to display all the errors, we return this as a 21 | // multi-error rather than bailing on the first error. 22 | return newMultiError(errors...) 23 | } 24 | 25 | func (c *Config) loadOrgConfig(orgName string) error { 26 | orgRepo, err := git.EnsureRepo(c.fs, "admin/org-"+orgName) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | err = orgRepo.Checkout(c.orgRepos[orgName]) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | data, err := orgRepo.GetFile("config.yml") 37 | if err != nil { 38 | return err 39 | } 40 | 41 | orgConfig, err := models.ParseOrgConfig(data) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | c.Orgs[orgName].Admin = append(c.Orgs[orgName].Admin, orgConfig.Admin...) 47 | c.Orgs[orgName].Write = append(c.Orgs[orgName].Write, orgConfig.Write...) 48 | c.Orgs[orgName].Read = append(c.Orgs[orgName].Read, orgConfig.Read...) 49 | 50 | if c.Options.OrgConfigRepos { 51 | for repoName, repo := range orgConfig.Repos { 52 | // If it's already defined, skip it. 53 | // 54 | // TODO: this should throw a validation error 55 | if _, ok := c.Orgs[orgName].Repos[repoName]; ok { 56 | continue 57 | } 58 | 59 | c.Orgs[orgName].Repos[repoName] = repo 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /config_user.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "github.com/belak/go-gitdir/internal/git" 5 | "github.com/belak/go-gitdir/models" 6 | ) 7 | 8 | func (c *Config) loadUserConfigs() error { 9 | // Bail early if we don't need to load anything. 10 | if !c.Options.UserConfigKeys && !c.Options.UserConfigRepos { 11 | return nil 12 | } 13 | 14 | errors := make([]error, 0, len(c.Users)) 15 | 16 | for username := range c.Users { 17 | errors = append(errors, c.loadUserConfig(username)) 18 | } 19 | 20 | // Because we want to display all the errors, we return this as a 21 | // multi-error rather than bailing on the first error. 22 | return newMultiError(errors...) 23 | } 24 | 25 | func (c *Config) loadUserConfig(username string) error { 26 | userRepo, err := git.EnsureRepo(c.fs, "admin/user-"+username) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | err = userRepo.Checkout(c.userRepos[username]) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if userRepo.FileExists("config.yml") { 37 | data, err := userRepo.GetFile("config.yml") 38 | if err != nil { 39 | return err 40 | } 41 | 42 | userConfig, err := models.ParseUserConfig(data) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | if c.Options.UserConfigKeys { 48 | c.Users[username].Keys = append(c.Users[username].Keys, userConfig.Keys...) 49 | } 50 | 51 | if c.Options.UserConfigRepos { 52 | for repoName, repo := range userConfig.Repos { 53 | // If it's already defined, skip it. 54 | // 55 | // TODO: this should throw a validation error 56 | if _, ok := c.Users[username].Repos[repoName]; ok { 57 | continue 58 | } 59 | 60 | c.Users[username].Repos[repoName] = repo 61 | } 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /config_validate.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/belak/go-gitdir/models" 9 | ) 10 | 11 | // Validate will ensure the config is valid and return any errors. 12 | func (c *Config) Validate(user *User, pk *models.PublicKey) error { 13 | return newMultiError( 14 | c.validateUser(user), 15 | c.validatePublicKey(pk), 16 | c.validateAdmins(), 17 | c.validateGroupLoop(), 18 | ) 19 | } 20 | 21 | func (c *Config) validateUser(u *User) error { 22 | if _, ok := c.Users[u.Username]; !ok { 23 | return fmt.Errorf("cannot remove current user: %s", u.Username) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func (c *Config) validatePublicKey(pk *models.PublicKey) error { 30 | if _, ok := c.publicKeys[pk.RawMarshalAuthorizedKey()]; !ok { 31 | return fmt.Errorf("cannot remove current private key: %s", pk.MarshalAuthorizedKey()) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func (c *Config) validateAdmins() error { 38 | for _, user := range c.Users { 39 | if user.IsAdmin { 40 | return nil 41 | } 42 | } 43 | 44 | return errors.New("no admins defined") 45 | } 46 | 47 | func (c *Config) validateGroupLoop() error { 48 | errors := make([]error, 0, len(c.Groups)) 49 | 50 | // Essentially this is "do a tree traversal on the groups" 51 | for groupName := range c.Groups { 52 | errors = append(errors, c.validateGroupLoopInternal(groupName, nil)) 53 | } 54 | 55 | return newMultiError(errors...) 56 | } 57 | 58 | func (c *Config) validateGroupLoopInternal(groupName string, groupPath []string) error { 59 | // If we hit a group loop, return the path to get here 60 | if listContainsStr(groupPath, groupName) { 61 | return fmt.Errorf("group loop found: %s", strings.Join(append(groupPath, groupName), ", ")) 62 | } 63 | 64 | groupPath = append(groupPath, groupName) 65 | 66 | for _, lookup := range c.Groups[groupName] { 67 | if strings.HasPrefix(lookup, groupPrefix) { 68 | intGroupName := strings.TrimPrefix(lookup, groupPrefix) 69 | 70 | if err := c.validateGroupLoopInternal(intGroupName, groupPath); err != nil { 71 | return err 72 | } 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gliderlabs/ssh" 7 | "github.com/go-git/go-billy/v5/memfs" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/belak/go-gitdir/models" 12 | ) 13 | 14 | type contextKey string 15 | 16 | func (c contextKey) String() string { 17 | return "Context key: " + string(c) 18 | } 19 | 20 | const ( 21 | contextKeyConfig = contextKey("gitdir-config") 22 | contextKeyUser = contextKey("gitdir-user") 23 | contextKeyLogger = contextKey("gitdir-logger") 24 | contextKeyPublicKey = contextKey("gitdir-public-key") 25 | ) 26 | 27 | // CtxExtract is a convenience wrapper around the other context convenience 28 | // methods to pull out everything you'd want from a request. 29 | func CtxExtract(ctx context.Context) (*zerolog.Logger, *Config, *User) { 30 | return CtxLogger(ctx), CtxConfig(ctx), CtxUser(ctx) 31 | } 32 | 33 | // CtxSetConfig puts the given Config into the ssh.Context. 34 | func CtxSetConfig(parent ssh.Context, config *Config) { 35 | parent.SetValue(contextKeyConfig, config) 36 | } 37 | 38 | // CtxConfig pulls the current Config out of the context, or a blank Config if 39 | // not set. 40 | func CtxConfig(ctx context.Context) *Config { 41 | if c, ok := ctx.Value(contextKeyConfig).(*Config); ok { 42 | return c 43 | } 44 | 45 | // If it doesn't exist, return a new empty config for safety. 46 | return NewConfig(memfs.New()) 47 | } 48 | 49 | // CtxSetUser puts the given User into the ssh.Context. 50 | func CtxSetUser(parent ssh.Context, user *User) { 51 | parent.SetValue(contextKeyUser, user) 52 | } 53 | 54 | // CtxUser pulls the current User out of the context, or AnonymousUser if not 55 | // set. 56 | func CtxUser(ctx context.Context) *User { 57 | if u, ok := ctx.Value(contextKeyUser).(*User); ok { 58 | return u 59 | } 60 | 61 | return AnonymousUser 62 | } 63 | 64 | // WithLogger takes a parent context and a logger and returns a new context 65 | // with that logger. 66 | func WithLogger(parent context.Context, logger *zerolog.Logger) context.Context { 67 | return context.WithValue(parent, contextKeyLogger, logger) 68 | } 69 | 70 | // CtxSetLogger puts the given logger into the ssh.Context. 71 | func CtxSetLogger(parent ssh.Context, logger *zerolog.Logger) { 72 | parent.SetValue(contextKeyLogger, logger) 73 | } 74 | 75 | // CtxLogger pulls the logger out of the context, or the default logger if not 76 | // found. 77 | func CtxLogger(ctx context.Context) *zerolog.Logger { 78 | if ctxLog, ok := ctx.Value(contextKeyLogger).(*zerolog.Logger); ok { 79 | return ctxLog 80 | } 81 | 82 | return &log.Logger 83 | } 84 | 85 | // CtxSetPublicKey puts the given public key into the ssh.Context. 86 | func CtxSetPublicKey(parent ssh.Context, pk *models.PublicKey) { 87 | parent.SetValue(contextKeyPublicKey, pk) 88 | } 89 | 90 | // CtxPublicKey pulls the public key out of the context, or nil if not found. 91 | func CtxPublicKey(ctx context.Context) *models.PublicKey { 92 | if pk, ok := ctx.Value(contextKeyPublicKey).(*models.PublicKey); ok { 93 | return pk 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/go-git/go-billy/v5/memfs" 8 | "github.com/rs/zerolog/log" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/belak/go-gitdir/models" 12 | ) 13 | 14 | func TestContextKey(t *testing.T) { 15 | t.Parallel() 16 | 17 | var tests = []struct { //nolint:gofumpt 18 | Input contextKey 19 | Base string 20 | Expected string 21 | }{ 22 | { 23 | contextKey("hello world"), 24 | "hello world", 25 | "Context key: hello world", 26 | }, 27 | { 28 | contextKeyConfig, 29 | "gitdir-config", 30 | "Context key: gitdir-config", 31 | }, 32 | { 33 | contextKeyUser, 34 | "gitdir-user", 35 | "Context key: gitdir-user", 36 | }, 37 | { 38 | contextKeyLogger, 39 | "gitdir-logger", 40 | "Context key: gitdir-logger", 41 | }, 42 | { 43 | contextKeyPublicKey, 44 | "gitdir-public-key", 45 | "Context key: gitdir-public-key", 46 | }, 47 | } 48 | 49 | baseCtx := context.Background() 50 | 51 | for _, test := range tests { 52 | assert.Equal(t, test.Expected, test.Input.String()) 53 | 54 | ctx := context.WithValue(baseCtx, test.Input, "hello world") 55 | 56 | // Make sure you can't pull values out with the raw string 57 | assert.Nil(t, ctx.Value(test.Base)) 58 | 59 | // Assert values come out properly 60 | assert.Equal(t, "hello world", ctx.Value(test.Input)) 61 | } 62 | } 63 | 64 | func TestCtxExtract(t *testing.T) { 65 | t.Parallel() 66 | 67 | ctx := context.Background() 68 | 69 | logger, config, user := CtxExtract(ctx) 70 | config.fs = nil 71 | 72 | assert.Equal(t, &log.Logger, logger) 73 | assert.Equal(t, NewConfig(nil), config) 74 | assert.Equal(t, AnonymousUser, user) 75 | } 76 | 77 | func TestCtxSetConfig(t *testing.T) { 78 | t.Skip("not implemented") 79 | 80 | t.Parallel() 81 | } 82 | 83 | func TestCtxConfig(t *testing.T) { 84 | t.Parallel() 85 | 86 | ctx := context.Background() 87 | 88 | // Check the default value 89 | config := CtxConfig(ctx) 90 | config.fs = nil 91 | assert.Equal(t, NewConfig(nil), config) 92 | 93 | // Check that when we set a value, this properly extracts it. 94 | config = NewConfig(memfs.New()) 95 | ctx = context.WithValue(ctx, contextKeyConfig, config) 96 | assert.Equal(t, config, CtxConfig(ctx)) 97 | } 98 | 99 | func TestCtxSetUser(t *testing.T) { 100 | t.Skip("not implemented") 101 | 102 | t.Parallel() 103 | } 104 | 105 | func TestCtxUser(t *testing.T) { 106 | t.Parallel() 107 | 108 | ctx := context.Background() 109 | 110 | // Check the default value 111 | assert.Equal(t, AnonymousUser, CtxUser(ctx)) 112 | 113 | // Check that when we set a value, this properly extracts it. 114 | user := &User{ 115 | Username: "belak", 116 | IsAdmin: true, 117 | } 118 | ctx = context.WithValue(ctx, contextKeyUser, user) 119 | assert.Equal(t, user, CtxUser(ctx)) 120 | } 121 | 122 | func TestCtxSetLogger(t *testing.T) { 123 | t.Skip("not implemented") 124 | 125 | t.Parallel() 126 | } 127 | 128 | func TestWithLogger(t *testing.T) { 129 | t.Skip("not implemented") 130 | 131 | t.Parallel() 132 | } 133 | 134 | func TestCtxLogger(t *testing.T) { 135 | t.Parallel() 136 | 137 | ctx := context.Background() 138 | 139 | // Check the default value 140 | assert.Equal(t, &log.Logger, CtxLogger(ctx)) 141 | 142 | // Check that when we set a value, this properly extracts it. 143 | logger := log.With().Str("hello", "world").Logger() 144 | ctx = context.WithValue(ctx, contextKeyLogger, &logger) 145 | assert.Equal(t, &logger, CtxLogger(ctx)) 146 | } 147 | 148 | func TestCtxSetPublicKey(t *testing.T) { 149 | t.Skip("not implemented") 150 | 151 | t.Parallel() 152 | } 153 | 154 | func TestCtxPublicKey(t *testing.T) { 155 | t.Parallel() 156 | 157 | ctx := context.Background() 158 | 159 | // Check the default value 160 | assert.Nil(t, CtxPublicKey(ctx)) 161 | 162 | // Check that when we set a value, this properly extracts it. 163 | pk := &models.PublicKey{} 164 | ctx = context.WithValue(ctx, contextKeyPublicKey, pk) 165 | assert.Equal(t, pk, CtxPublicKey(ctx)) 166 | } 167 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Build and deploy steps: 2 | # 3 | # 01. Create a .env file and set `GITDIR_ADMIN_USER` to the name you want to use 4 | # for the admin user and `GITDIR_ADMIN_PUBLIC_KEY` to the contents of a 5 | # public key. You can also set these environment variables using another 6 | # method. 7 | # 02. After `gitdir` starts up, you may try `git clone 8 | # ssh://@:2222/admin.git` to clone the admin 9 | # repository of gitdir 10 | # 03. To learn how to create repositories and more, see the home page of 11 | # `gitdir` 12 | 13 | version: "3.7" 14 | 15 | volumes: 16 | gitdir: 17 | 18 | services: 19 | gitdir: 20 | container_name: gitdir 21 | build: . 22 | restart: unless-stopped 23 | ports: 24 | - "0.0.0.0:2222:2222/tcp" 25 | volumes: 26 | - gitdir:/var/lib/gitdir 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/belak/go-gitdir 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/gliderlabs/ssh v0.3.6 7 | github.com/go-git/go-billy/v5 v5.5.0 8 | github.com/go-git/go-git/v5 v5.11.0 9 | github.com/joho/godotenv v1.4.0 10 | github.com/rs/zerolog v1.32.0 11 | github.com/stretchr/testify v1.8.4 12 | golang.org/x/crypto v0.21.0 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | dario.cat/mergo v1.0.0 // indirect 18 | github.com/Microsoft/go-winio v0.6.1 // indirect 19 | github.com/ProtonMail/go-crypto v1.0.0 // indirect 20 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 21 | github.com/cloudflare/circl v1.3.7 // indirect 22 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/emirpasic/gods v1.18.1 // indirect 25 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 26 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 27 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 28 | github.com/kevinburke/ssh_config v1.2.0 // indirect 29 | github.com/mattn/go-colorable v0.1.13 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/pjbgf/sha1cd v0.3.0 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/sergi/go-diff v1.3.1 // indirect 34 | github.com/skeema/knownhosts v1.2.2 // indirect 35 | github.com/xanzy/ssh-agent v0.3.3 // indirect 36 | golang.org/x/mod v0.16.0 // indirect 37 | golang.org/x/net v0.22.0 // indirect 38 | golang.org/x/sys v0.18.0 // indirect 39 | golang.org/x/tools v0.19.0 // indirect 40 | gopkg.in/warnings.v0 v0.1.2 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 7 | github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= 8 | github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 12 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 13 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 14 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 15 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 16 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 17 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 18 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 19 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 20 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 21 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 22 | github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 23 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= 28 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 29 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 30 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 31 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 32 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 33 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 34 | github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 35 | github.com/gliderlabs/ssh v0.3.6 h1:ZzjlDa05TcFRICb3anf/dSPN3ewz1Zx6CMLPWgkm3b8= 36 | github.com/gliderlabs/ssh v0.3.6/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= 37 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 38 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 39 | github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= 40 | github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= 41 | github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= 42 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 43 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 44 | github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= 45 | github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= 46 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 47 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 48 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 49 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 50 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 51 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 52 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 53 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 54 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 55 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 56 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 57 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 58 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 59 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 60 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 61 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 62 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 63 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 64 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 65 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 66 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 67 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 68 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 69 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 70 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 71 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 72 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 73 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 74 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 75 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 76 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 77 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 78 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 79 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 80 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 81 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 82 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 83 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 84 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 85 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 86 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 87 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 88 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 89 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 90 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 91 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 92 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 93 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 94 | github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= 95 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 96 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 97 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 98 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 99 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 100 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 101 | github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 102 | github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= 103 | github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= 104 | github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= 105 | github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 106 | github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 107 | github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 108 | github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= 109 | github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= 110 | github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= 111 | github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= 112 | github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 113 | github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= 114 | github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= 115 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 116 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 117 | github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 118 | github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 119 | github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= 120 | github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= 121 | github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= 122 | github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 123 | github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 124 | github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 125 | github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= 126 | github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= 127 | github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ= 128 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 129 | github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= 130 | github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ= 131 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 132 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 133 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 134 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 135 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 136 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 137 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 138 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 139 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 140 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 141 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 142 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 143 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 144 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 145 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= 146 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 147 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 148 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 149 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 150 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 151 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 152 | github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 153 | github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= 154 | github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 155 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 156 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 157 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 158 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 159 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 160 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 161 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 162 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 163 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 164 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 165 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 166 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 167 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 168 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 169 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 170 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 171 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 172 | golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 173 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 174 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 175 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 176 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 177 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 178 | golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 179 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 180 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 181 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 182 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 183 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 184 | golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 185 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 186 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 187 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 188 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 189 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 190 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 191 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 192 | golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 193 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 194 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 195 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 196 | golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 197 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 198 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 199 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 200 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 201 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 202 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 203 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 204 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 205 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 206 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 207 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 208 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 209 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 210 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 211 | golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 212 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 213 | golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 214 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 215 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 216 | golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 217 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 218 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 219 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 220 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 221 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 222 | golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= 223 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 224 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 225 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 226 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 227 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 228 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 229 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 230 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 231 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 232 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 233 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 234 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 235 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 236 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 237 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 238 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 239 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 240 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 241 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 248 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 251 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 252 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 253 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 254 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 255 | golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 256 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 257 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 258 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 259 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 260 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 261 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 262 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 263 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 264 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 265 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 266 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 267 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 268 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 269 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 270 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 271 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 272 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 273 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 274 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 275 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 276 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 277 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 278 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 279 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 280 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 281 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 282 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 283 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 284 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 285 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 286 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 287 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 288 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 289 | golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= 290 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 291 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 292 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 293 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 294 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 295 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 296 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 297 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 298 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 299 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 300 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 301 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 302 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 303 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 304 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 305 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 306 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 307 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 308 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 309 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 310 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 311 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 312 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 313 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 314 | golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 315 | golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 316 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 317 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 318 | golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= 319 | golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= 320 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 321 | golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= 322 | golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= 323 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 324 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 325 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 326 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 327 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 328 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 329 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 330 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 331 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 332 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 333 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 334 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 335 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 336 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 337 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 338 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 339 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 340 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 341 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 342 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 343 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 344 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 345 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 346 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 347 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 348 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 349 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 350 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 351 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 352 | -------------------------------------------------------------------------------- /hooks.go: -------------------------------------------------------------------------------- 1 | //nolint:forbidigo 2 | package gitdir 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/belak/go-gitdir/models" 10 | ) 11 | 12 | // RunHook will run the given hook. 13 | func (c *Config) RunHook( 14 | hook string, 15 | repoPath string, 16 | pk *models.PublicKey, 17 | args []string, 18 | stdin io.Reader, 19 | ) error { 20 | user, err := c.LookupUserFromKey(*pk, c.Options.GitUser) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | repo, err := c.LookupRepoAccess(user, repoPath) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | switch hook { 31 | case "pre-receive", "post-receive": 32 | // Pre and post are here just in case we need them in the future, but 33 | // they always succeed right now. 34 | return nil 35 | case "update": 36 | if len(args) < 3 { 37 | return errors.New("not enough args") 38 | } 39 | 40 | var ( 41 | ref = args[0] 42 | oldHash = args[1] 43 | newHash = args[2] 44 | ) 45 | 46 | fmt.Println(args) 47 | 48 | return c.runUpdateHook(repo, user, pk, oldHash, newHash, ref) 49 | default: 50 | return fmt.Errorf("hook %s is not implemented", hook) 51 | } 52 | } 53 | 54 | func (c *Config) runUpdateHook( 55 | lookup *RepoLookup, 56 | user *User, 57 | pk *models.PublicKey, 58 | oldHash string, 59 | newHash string, 60 | ref string, 61 | ) error { 62 | var err error 63 | 64 | switch lookup.Type { 65 | case RepoTypeAdmin: 66 | err = c.SetHash(newHash) 67 | case RepoTypeOrgConfig: 68 | err = c.SetOrgHash(lookup.PathParts[0], newHash) 69 | case RepoTypeUserConfig: 70 | err = c.SetUserHash(lookup.PathParts[0], newHash) 71 | default: 72 | // Non-admin repos don't need this hook. 73 | return nil 74 | } 75 | 76 | if err != nil { 77 | return err 78 | } 79 | 80 | err = c.Load() 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return c.Validate(user, pk) 86 | } 87 | -------------------------------------------------------------------------------- /internal/git/operations.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "io" 5 | 6 | git "github.com/go-git/go-git/v5" 7 | "github.com/go-git/go-git/v5/plumbing/object" 8 | ) 9 | 10 | // GetFile is a convenience method to get the contents of a file in the repo. 11 | func (r *Repository) GetFile(filename string) ([]byte, error) { 12 | f, err := r.WorktreeFS.Open(filename) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return io.ReadAll(f) 18 | } 19 | 20 | // FileExists returns true if the given path exists and is a regular file in the 21 | // repository worktree. 22 | func (r *Repository) FileExists(filename string) bool { 23 | stat, err := r.WorktreeFS.Stat(filename) 24 | if err != nil { 25 | return false 26 | } 27 | 28 | return stat.Mode().IsRegular() 29 | } 30 | 31 | // DirExists returns true if the given path exists and is a directory in the 32 | // repository worktree. 33 | func (r *Repository) DirExists(filename string) bool { 34 | stat, err := r.WorktreeFS.Stat(filename) 35 | if err != nil { 36 | return false 37 | } 38 | 39 | return stat.Mode().IsDir() 40 | } 41 | 42 | // CreateFile is a convenience method to set the contents of a file in the 43 | // repo and stage it. 44 | func (r *Repository) CreateFile(filename string, data []byte) error { 45 | f, err := r.WorktreeFS.Create(filename) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | _, err = f.Write(data) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | _, err = r.Worktree.Add(filename) 56 | 57 | return err 58 | } 59 | 60 | // UpdateFile is a convenience method to get the contents of a file, pass it 61 | // to a callback, write the contents back to the file if no error was 62 | // returned, and stage the file. 63 | func (r *Repository) UpdateFile(filename string, cb func([]byte) ([]byte, error)) error { 64 | data, _ := r.GetFile(filename) 65 | 66 | data, err := cb(data) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return r.CreateFile(filename, data) 72 | } 73 | 74 | // Commit is a convenience method to make working with the worktree a little 75 | // bit easier. 76 | func (r *Repository) Commit(msg string, author *object.Signature) error { 77 | if author == nil { 78 | author = newAdminGitSignature() 79 | } 80 | 81 | _, err := r.Worktree.Commit(msg, &git.CommitOptions{ 82 | Author: author, 83 | }) 84 | 85 | return err 86 | } 87 | -------------------------------------------------------------------------------- /internal/git/repository.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | billy "github.com/go-git/go-billy/v5" 12 | "github.com/go-git/go-billy/v5/memfs" 13 | "github.com/go-git/go-billy/v5/util" 14 | git "github.com/go-git/go-git/v5" 15 | "github.com/go-git/go-git/v5/plumbing" 16 | "github.com/go-git/go-git/v5/plumbing/cache" 17 | "github.com/go-git/go-git/v5/storage/filesystem" 18 | ) 19 | 20 | // Repository is a fairly lightweight wrapper designed to attach a worktree to a 21 | // git repository so we don't have to muck about with the raw git objects. 22 | // 23 | // We keep the worktree separate from the repo so we can still have a bare repo. 24 | // This also lets us do fun things like keep the worktree in memory if we really 25 | // want to. 26 | type Repository struct { 27 | Repo *git.Repository 28 | RepoFS *filesystem.Storage 29 | Worktree *git.Worktree 30 | WorktreeFS billy.Filesystem 31 | } 32 | 33 | // Open will open a repository if it exists. 34 | func Open(baseFS billy.Filesystem, path string) (*Repository, error) { 35 | // This lets us sanitize the path and ensure it always has .git on the end. 36 | path = strings.TrimSuffix(path, ".git") + ".git" 37 | 38 | fs, err := baseFS.Chroot(path) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | repoFS := filesystem.NewStorage(fs, cache.NewObjectLRUDefault()) 44 | 45 | // TODO: this probably shouldn't be memfs. 46 | worktreeFS := memfs.New() 47 | 48 | repo, err := git.Open(repoFS, worktreeFS) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | worktree, err := repo.Worktree() 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return &Repository{ 59 | Repo: repo, 60 | RepoFS: repoFS, 61 | Worktree: worktree, 62 | WorktreeFS: worktreeFS, 63 | }, nil 64 | } 65 | 66 | // EnsureRepo will open a repository if it exists and try to create it if it 67 | // doesn't. 68 | func EnsureRepo(baseFS billy.Filesystem, path string) (*Repository, error) { 69 | // This lets us sanitize the path and ensure it always has .git on the end. 70 | path = strings.TrimSuffix(path, ".git") + ".git" 71 | 72 | if !dirExists(baseFS, path) { 73 | oldPath := strings.TrimSuffix(path, ".git") 74 | 75 | // If the old dir exists, rename it. Otherwize, init the repo. 76 | if dirExists(baseFS, oldPath) { 77 | err := baseFS.Rename(oldPath, path) 78 | if err != nil { 79 | return nil, err 80 | } 81 | } else { 82 | fs, err := baseFS.Chroot(path) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | repoFS := filesystem.NewStorage(fs, cache.NewObjectLRUDefault()) 88 | 89 | // Init the repo without a worktree so it's a bare repo. 90 | _, err = git.Init(repoFS, nil) 91 | if err != nil { 92 | return nil, err 93 | } 94 | } 95 | } 96 | 97 | repo, err := Open(baseFS, path) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | err = ensureHooks(repo.RepoFS.Filesystem()) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | return repo, nil 108 | } 109 | 110 | // Checkout will checkout the given hash to the worktreeFS. If an empty string 111 | // is given, we checkout master. 112 | func (r *Repository) Checkout(hash string) error { 113 | opts := &git.CheckoutOptions{ 114 | Force: true, 115 | } 116 | 117 | if hash != "" { 118 | opts.Hash = plumbing.NewHash(hash) 119 | } 120 | 121 | err := r.Worktree.Checkout(opts) 122 | 123 | // It's fine to ignore ErrReferenceNotFound because that means this is a 124 | // repo without any commits which doesn't matter for our use cases. 125 | if err != nil && !errors.Is(err, plumbing.ErrReferenceNotFound) { 126 | return err 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func ensureHooks(fs billy.Filesystem) error { 133 | exe, err := os.Executable() 134 | if err != nil { 135 | return err 136 | } 137 | 138 | for _, hook := range hooks { 139 | err := fs.MkdirAll("hooks/"+hook.Name+".d", os.ModePerm) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | // Write the gitdir hook 145 | err = writeIfDifferent( 146 | fs, 147 | "hooks/"+hook.Name+".d/gitdir", 148 | []byte(fmt.Sprintf(hook.GitdirHookTemplate, exe)), 149 | ) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | // Write out the actual hook 155 | // 156 | // TODO: warn when this would clobber a file 157 | err = writeIfDifferent(fs, "hooks/"+hook.Name, []byte(hookTemplate)) 158 | if err != nil { 159 | return err 160 | } 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func writeIfDifferent(fs billy.Basic, path string, data []byte) error { 167 | var oldData []byte 168 | 169 | f, err := fs.Open(path) 170 | if err == nil { 171 | defer f.Close() 172 | oldData, _ = io.ReadAll(f) 173 | } 174 | 175 | // Quick check to avoid unneeded writes 176 | if !bytes.Equal(oldData, data) { 177 | return util.WriteFile(fs, path, data, os.ModePerm) 178 | } 179 | 180 | return nil 181 | } 182 | 183 | // hookTemplate is based on a combination of sources, but allows us to run 184 | // multiple hooks from a directory (all of them will always be run) and only 185 | // fail if at least one of them failed. This should support every type of git 186 | // hook, as it proxies both stdin and arguments. 187 | var hookTemplate = `#!/usr/bin/env sh 188 | set -e 189 | test -n "${GIT_DIR}" || exit 1 190 | 191 | stdin=$(cat) 192 | hookname=$(basename $0) 193 | exitcodes="" 194 | 195 | for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do 196 | # Avoid running non-executable hooks 197 | test -x "${hook}" || continue 198 | 199 | # Run the actual hook 200 | echo "${stdin}" | "${hook}" "$@" 201 | 202 | # Store the exit code for later use 203 | exitcodes="${exitcodes} $?" 204 | done 205 | 206 | # Exit on the first non-zero exit code. 207 | for code in ${exitcodes}; do 208 | test ${code} -eq 0 || exit ${i} 209 | done 210 | 211 | exit 0 212 | ` 213 | 214 | var hooks = []struct { 215 | Name string 216 | GitdirHookTemplate string 217 | }{ 218 | { 219 | Name: "pre-receive", 220 | GitdirHookTemplate: `#!/usr/bin/env sh 221 | 222 | if [ -z "$GITDIR_BASE_DIR" ]; then 223 | echo "Warning: GITDIR_BASE_DIR not defined. Skipping hooks." 224 | exit 0 225 | fi 226 | 227 | %q hook pre-receive 228 | `, 229 | }, 230 | { 231 | Name: "update", 232 | GitdirHookTemplate: `#!/usr/bin/env sh 233 | 234 | if [ -z "$GITDIR_BASE_DIR" ]; then 235 | echo "Warning: GITDIR_BASE_DIR not defined. Skipping hooks." 236 | exit 0 237 | fi 238 | 239 | %q hook update $1 $2 $3 240 | `, 241 | }, 242 | { 243 | Name: "post-receive", 244 | GitdirHookTemplate: `#!/usr/bin/env sh 245 | 246 | if [ -z "$GITDIR_BASE_DIR" ]; then 247 | echo "Warning: GITDIR_BASE_DIR not defined. Skipping hooks." 248 | exit 0 249 | fi 250 | 251 | %q hook post-receive 252 | `, 253 | }, 254 | } 255 | -------------------------------------------------------------------------------- /internal/git/utils.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "time" 5 | 6 | billy "github.com/go-git/go-billy/v5" 7 | "github.com/go-git/go-git/v5/plumbing/object" 8 | ) 9 | 10 | func newAdminGitSignature() *object.Signature { 11 | return &object.Signature{ 12 | Name: "root", 13 | Email: "root@localhost", 14 | When: time.Now(), 15 | } 16 | } 17 | 18 | func dirExists(fs billy.Filesystem, path string) bool { 19 | info, err := fs.Stat(path) 20 | if err != nil { 21 | return false 22 | } 23 | 24 | return info.IsDir() 25 | } 26 | -------------------------------------------------------------------------------- /internal/yaml/encode.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "bytes" 5 | 6 | yaml "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // Encode is a convenience function which will encode the given node to a byte 10 | // slice. 11 | func (n *Node) Encode() ([]byte, error) { 12 | // All this is really so we can set indentation to 2 spaces. 13 | buf := &bytes.Buffer{} 14 | enc := yaml.NewEncoder(buf) 15 | 16 | enc.SetIndent(2) 17 | 18 | err := enc.Encode(n.Node) 19 | 20 | return buf.Bytes(), err 21 | } 22 | -------------------------------------------------------------------------------- /internal/yaml/node.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | yaml "gopkg.in/yaml.v3" 5 | ) 6 | 7 | // Node is a simple wrapper around the lower level yaml Node. 8 | type Node struct { 9 | *yaml.Node 10 | } 11 | 12 | // NewMappingNode returns a new node pointing to a yaml map. 13 | func NewMappingNode() *Node { 14 | return &Node{ 15 | &yaml.Node{Kind: yaml.MappingNode}, 16 | } 17 | } 18 | 19 | // NewSequenceNode returns a new node pointing to a yaml list. 20 | func NewSequenceNode() *Node { 21 | return &Node{ 22 | &yaml.Node{Kind: yaml.SequenceNode}, 23 | } 24 | } 25 | 26 | // ScalarTag represents the type hints used by yaml. These come from the yaml 27 | // source. 28 | type ScalarTag string 29 | 30 | // Each of these was taken from the yaml source. There are additional type 31 | // hints, but they seem to be for specific non-scalar values. 32 | const ( 33 | ScalarTagString ScalarTag = "!!str" 34 | ScalarTagBool ScalarTag = "!!bool" 35 | ScalarTagInt ScalarTag = "!!int" 36 | ScalarTagFloat ScalarTag = "!!float" 37 | ScalarTagTimestamp ScalarTag = "!!timestamp" 38 | ScalarTagBinary ScalarTag = "!!binary" 39 | ) 40 | 41 | // NewScalarNode returns a new node pointing to a yaml value. To use the default 42 | // type hinting, use the empty string as the tag. 43 | func NewScalarNode(value string, tag ScalarTag) *Node { 44 | return &Node{ 45 | &yaml.Node{ 46 | Kind: yaml.ScalarNode, 47 | Value: value, 48 | Tag: string(tag), 49 | }, 50 | } 51 | } 52 | 53 | // KeyIndex finds the index of the given key or -1 if not found. Note that it 54 | // will only return an index if index + 1 also exists. 55 | func (n *Node) KeyIndex(key string) int { 56 | for i := 0; i+1 < len(n.Content); i += 2 { 57 | if n.Content[i].Kind == yaml.ScalarNode && n.Content[i].Value == key { 58 | return i 59 | } 60 | } 61 | 62 | return -1 63 | } 64 | 65 | // ValueNode returns the given value node for a key, or nil if not found. 66 | func (n *Node) ValueNode(key string) *Node { 67 | if idx := n.KeyIndex(key); idx != -1 { 68 | return &Node{n.Content[idx+1]} 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/yaml/operations.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "errors" 5 | 6 | yaml "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // RemoveKey will remove a given key and value from a MappingNode. 10 | func (n *Node) RemoveKey(key string) bool { 11 | idx := n.KeyIndex(key) 12 | if idx == -1 { 13 | return false 14 | } 15 | 16 | // Removing the index of the target node twice should drop the key and 17 | // value. 18 | n.Content = remove(n.Content, idx) 19 | n.Content = remove(n.Content, idx) 20 | 21 | return true 22 | } 23 | 24 | // EnsureOptions are optional settings when using Node.EnsureKey. 25 | type EnsureOptions struct { 26 | // Comment lets you specify a comment for this node, if it's added. 27 | Comment string 28 | 29 | // Force will add the key if it doesn't exist and replace it if it does. If 30 | // Force is used, the comment will always be overridden. 31 | Force bool 32 | } 33 | 34 | // EnsureKey ensures that a given key exists. If it doesn't, it adds it with the 35 | // value pointing to newNode. 36 | func (n *Node) EnsureKey(key string, newNode *Node, opts *EnsureOptions) (*Node, bool) { 37 | if opts == nil { 38 | opts = &EnsureOptions{} 39 | } 40 | 41 | valNode := n.ValueNode(key) 42 | 43 | if valNode == nil { 44 | n.Content = append( 45 | n.Content, 46 | &yaml.Node{ 47 | Kind: yaml.ScalarNode, 48 | Value: key, 49 | HeadComment: opts.Comment, 50 | }, 51 | newNode.Node, 52 | ) 53 | 54 | return newNode, true 55 | } 56 | 57 | if opts.Force { 58 | // Replace the node and update the comment 59 | *(valNode.Node) = *(newNode.Node) 60 | valNode.HeadComment = opts.Comment 61 | 62 | return valNode, true 63 | } 64 | 65 | return valNode, false 66 | } 67 | 68 | // AppendNode will append a value to a SequenceNode. 69 | func (n *Node) AppendNode(newNode *Node) { 70 | n.Content = append(n.Content, newNode.Node) 71 | } 72 | 73 | // AppendNode will append a scalar to a SequenceNode if it does not already 74 | // exist. 75 | func (n *Node) AppendUniqueScalar(newNode *Node) bool { 76 | for _, iterNode := range n.Content { 77 | if iterNode.Kind != yaml.ScalarNode { 78 | continue 79 | } 80 | 81 | if iterNode.Value == newNode.Value { 82 | return false 83 | } 84 | } 85 | 86 | n.AppendNode(newNode) 87 | 88 | return true 89 | } 90 | 91 | // EnsureDocument takes data from a yaml file and ensures a basic document 92 | // structure. It returns the root node, the root content node, or an error if 93 | // the yaml document isn't in a valid format. 94 | func EnsureDocument(data []byte) (*Node, *Node, error) { 95 | rootNode := &yaml.Node{ 96 | Kind: yaml.DocumentNode, 97 | } 98 | 99 | // We explicitly ignore this error so we can manually make a tree 100 | _ = yaml.Unmarshal(data, rootNode) 101 | 102 | if len(rootNode.Content) == 0 { 103 | rootNode.Content = append(rootNode.Content, &yaml.Node{ 104 | Kind: yaml.MappingNode, 105 | }) 106 | } 107 | 108 | if len(rootNode.Content) != 1 || rootNode.Content[0].Kind != yaml.MappingNode { 109 | return nil, nil, errors.New("root is not a valid yaml document") 110 | } 111 | 112 | targetNode := rootNode.Content[0] 113 | 114 | return &Node{rootNode}, &Node{targetNode}, nil 115 | } 116 | -------------------------------------------------------------------------------- /internal/yaml/utils.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | yaml "gopkg.in/yaml.v3" 5 | ) 6 | 7 | func remove(slice []*yaml.Node, s int) []*yaml.Node { 8 | return append(slice[:s], slice[s+1:]...) 9 | } 10 | -------------------------------------------------------------------------------- /models/admin_config.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | yaml "gopkg.in/yaml.v3" 5 | ) 6 | 7 | // AdminConfig is the config.yml that comes from the admin repo. 8 | type AdminConfig struct { 9 | Invites map[string]string `yaml:"invites"` 10 | Users map[string]*AdminConfigUser `yaml:"users"` 11 | Orgs map[string]*OrgConfig `yaml:"orgs"` 12 | Repos map[string]*RepoConfig `yaml:"repos"` 13 | Groups map[string][]string `yaml:"groups"` 14 | Options AdminConfigOptions `yaml:"options"` 15 | } 16 | 17 | // AdminConfigUser defines additional fields which main be loaded from the admin 18 | // config. 19 | type AdminConfigUser struct { 20 | UserConfig `yaml:",inline"` 21 | 22 | IsAdmin bool `yaml:"is_admin"` 23 | Disabled bool `yaml:"disabled"` 24 | // Invites []string `yaml:"invites"` 25 | } 26 | 27 | // NewAdminConfigUser returns a blank AdminConfigUser. 28 | func NewAdminConfigUser() *AdminConfigUser { 29 | return &AdminConfigUser{ 30 | UserConfig: *NewUserConfig(), 31 | } 32 | } 33 | 34 | // AdminConfigOptions contains all the server level settings which can be 35 | // changed at runtime. 36 | type AdminConfigOptions struct { 37 | // GitUser refers to which username to use as the global git user. 38 | GitUser string `yaml:"git_user"` 39 | 40 | // OrgPrefix refers to the prefix to use when cloning org repos. 41 | OrgPrefix string `yaml:"org_prefix"` 42 | 43 | // UserPrefix refers to the prefix to use when cloning user repos. 44 | UserPrefix string `yaml:"user_prefix"` 45 | 46 | // InvitePrefix refers to the prefix to use when sshing in with an invite. 47 | InvitePrefix string `yaml:"invite_prefix"` 48 | 49 | // ImplicitRepos allows a user with admin access to that area to create 50 | // repos by simply pushing to them. 51 | ImplicitRepos bool `yaml:"implicit_repos"` 52 | 53 | // UserConfigKeys allows users to specify ssh keys in their own config, 54 | // rather than relying on the main admin config. 55 | UserConfigKeys bool `yaml:"user_config_keys"` 56 | 57 | // UserConfigRepos allows users to specify repos in their own config, rather 58 | // than relying on the main admin config. 59 | UserConfigRepos bool `yaml:"user_config_repos"` 60 | 61 | // OrgConfig allows org admins to configure orgs in their own config, rather 62 | // than relying on the main admin config. 63 | OrgConfig bool `yaml:"org_config"` 64 | 65 | // OrgConfigRepos allows org admins to specify repos in their own config, 66 | // rather than relying on the main admin config. 67 | OrgConfigRepos bool `yaml:"org_config_repos"` 68 | } 69 | 70 | // DefaultAdminConfigOptions is an object with all values set to their default. 71 | var DefaultAdminConfigOptions = AdminConfigOptions{ 72 | GitUser: "git", 73 | OrgPrefix: "@", 74 | UserPrefix: "~", 75 | InvitePrefix: "invite:", 76 | } 77 | 78 | // NewAdminConfig returns a blank admin config with any defaults set. 79 | func NewAdminConfig() *AdminConfig { 80 | return &AdminConfig{ 81 | Invites: make(map[string]string), 82 | Users: make(map[string]*AdminConfigUser), 83 | Orgs: make(map[string]*OrgConfig), 84 | Repos: make(map[string]*RepoConfig), 85 | Groups: make(map[string][]string), 86 | 87 | // Defaults. These should be set in ensure config, but we have them here 88 | // for reference. 89 | Options: DefaultAdminConfigOptions, 90 | } 91 | } 92 | 93 | // ParseAdminConfig will return an AdminConfig parsed from the given data. No 94 | // additional validation is done. 95 | func ParseAdminConfig(data []byte) (*AdminConfig, error) { 96 | ac := NewAdminConfig() 97 | 98 | err := yaml.Unmarshal(data, ac) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return ac, nil 104 | } 105 | -------------------------------------------------------------------------------- /models/org_config.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | ) 6 | 7 | // OrgConfig represents the values under orgs in the main admin config or the 8 | // contents of the config file in the org config repo. 9 | type OrgConfig struct { 10 | Admin []string `yaml:"admin"` 11 | Write []string `yaml:"write"` 12 | Read []string `yaml:"read"` 13 | Repos map[string]*RepoConfig `yaml:"repos"` 14 | } 15 | 16 | // NewOrgConfig returns a new, empty OrgConfig. 17 | func NewOrgConfig() *OrgConfig { 18 | return &OrgConfig{ 19 | Repos: make(map[string]*RepoConfig), 20 | } 21 | } 22 | 23 | // ParseOrgConfig will return an OrgConfig parsed from the given data. No 24 | // additional validation is done. 25 | func ParseOrgConfig(data []byte) (*OrgConfig, error) { 26 | oc := NewOrgConfig() 27 | 28 | err := yaml.Unmarshal(data, oc) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return oc, nil 34 | } 35 | -------------------------------------------------------------------------------- /models/private_key.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "errors" 11 | 12 | gossh "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // PrivateKey is a wrapper to make dealing with private keys easier to deal 16 | // with. 17 | type PrivateKey interface { 18 | crypto.Signer 19 | 20 | MarshalPrivateKey() ([]byte, error) 21 | } 22 | 23 | type ed25519PrivateKey struct { 24 | ed25519.PrivateKey 25 | } 26 | 27 | // ParseEd25519PrivateKey parses an ed25519 private key. 28 | func ParseEd25519PrivateKey(data []byte) (PrivateKey, error) { 29 | privateKey, err := gossh.ParseRawPrivateKey(data) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | // Try loading as an external key, fall back to internal key. This *should* 35 | // fix issues with incompatible versions. 36 | ed25519Key, ok := privateKey.(ed25519.PrivateKey) 37 | if !ok { 38 | return nil, errors.New("not an ed25519 key") 39 | } 40 | 41 | return &ed25519PrivateKey{ed25519Key}, nil 42 | } 43 | 44 | // GenerateEd25519PrivateKey generates a new ed25519 private key. 45 | func GenerateEd25519PrivateKey() (PrivateKey, error) { 46 | _, pk, err := ed25519.GenerateKey(rand.Reader) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return &ed25519PrivateKey{pk}, err 52 | } 53 | 54 | // MarshalPrivateKey implements PrivateKey.MarshalPrivateKey. 55 | func (pk *ed25519PrivateKey) MarshalPrivateKey() ([]byte, error) { 56 | // Get ASN.1 DER format 57 | privDER, err := x509.MarshalPKCS8PrivateKey(pk.PrivateKey) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | // pem.Block 63 | privBlock := pem.Block{ 64 | Type: "PRIVATE KEY", 65 | Headers: nil, 66 | Bytes: privDER, 67 | } 68 | 69 | // Private key in PEM format 70 | privatePEM := pem.EncodeToMemory(&privBlock) 71 | 72 | return privatePEM, nil 73 | } 74 | 75 | type rsaPrivateKey struct { 76 | *rsa.PrivateKey 77 | } 78 | 79 | // ParseRSAPrivateKey parses an RSA private key. 80 | func ParseRSAPrivateKey(data []byte) (PrivateKey, error) { 81 | privateKey, err := gossh.ParseRawPrivateKey(data) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | rsaKey, ok := privateKey.(*rsa.PrivateKey) 87 | if !ok { 88 | return nil, errors.New("not an RSA key") 89 | } 90 | 91 | return &rsaPrivateKey{rsaKey}, nil 92 | } 93 | 94 | // GenerateRSAPrivateKey generates a new RSA private key of size 4096. 95 | func GenerateRSAPrivateKey() (PrivateKey, error) { 96 | // Private Key generation 97 | privateKey, err := rsa.GenerateKey(rand.Reader, 4096) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | // Validate Private Key 103 | err = privateKey.Validate() 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return &rsaPrivateKey{privateKey}, nil 109 | } 110 | 111 | // MarshalPrivateKey implements PrivateKey.MarshalPrivateKey. 112 | func (pk *rsaPrivateKey) MarshalPrivateKey() ([]byte, error) { 113 | // Get ASN.1 DER format 114 | privDER := x509.MarshalPKCS1PrivateKey(pk.PrivateKey) 115 | 116 | // pem.Block 117 | privBlock := pem.Block{ 118 | Type: "RSA PRIVATE KEY", 119 | Headers: nil, 120 | Bytes: privDER, 121 | } 122 | 123 | // Private key in PEM format 124 | privatePEM := pem.EncodeToMemory(&privBlock) 125 | 126 | return privatePEM, nil 127 | } 128 | -------------------------------------------------------------------------------- /models/public_key.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/gliderlabs/ssh" 7 | gossh "golang.org/x/crypto/ssh" 8 | ) 9 | 10 | // PublicKey is a wrapper around gossh.PublicKey to also store the comment. 11 | // Note when using that pk.Marshal() handles the wire format, not the 12 | // authorized keys format. 13 | type PublicKey struct { 14 | ssh.PublicKey 15 | 16 | Comment string 17 | } 18 | 19 | // ParsePublicKey will return a PublicKey from the given data. 20 | func ParsePublicKey(data []byte) (*PublicKey, error) { 21 | var err error 22 | 23 | var pk PublicKey 24 | 25 | pk.PublicKey, pk.Comment, _, _, err = ssh.ParseAuthorizedKey(data) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &pk, nil 31 | } 32 | 33 | // UnmarshalYAML implements yaml.Unmarshaler.UnmarshalYAML. 34 | func (pk *PublicKey) UnmarshalYAML(unmarshal func(v interface{}) error) error { 35 | var rawData string 36 | 37 | err := unmarshal(&rawData) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | pk.PublicKey, pk.Comment, _, _, err = ssh.ParseAuthorizedKey([]byte(rawData)) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // String implements fmt.Stringer. 51 | func (pk *PublicKey) String() string { 52 | return pk.MarshalAuthorizedKey() 53 | } 54 | 55 | // RawMarshalAuthorizedKey converts a key to the authorized keys format, 56 | // without the comment. 57 | func (pk *PublicKey) RawMarshalAuthorizedKey() string { 58 | if pk == nil || pk.PublicKey == nil { 59 | return "" 60 | } 61 | 62 | return string(bytes.TrimSpace(gossh.MarshalAuthorizedKey(pk))) 63 | } 64 | 65 | // MarshalAuthorizedKey converts a key to the authorized keys format, 66 | // including a comment. 67 | func (pk *PublicKey) MarshalAuthorizedKey() string { 68 | key := pk.RawMarshalAuthorizedKey() 69 | 70 | if pk.Comment != "" { 71 | return key + " " + pk.Comment 72 | } 73 | 74 | return key 75 | } 76 | -------------------------------------------------------------------------------- /models/repo_config.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // RepoConfig represents the values under repos in the main admin config, any 4 | // org configs, or any user configs. 5 | type RepoConfig struct { 6 | // Public allows any user of the service to access this repository for 7 | // reading 8 | Public bool 9 | 10 | // Any user or group who explicitly has write access 11 | Write []string 12 | 13 | // Any user or group who explicitly has read access 14 | Read []string 15 | } 16 | 17 | // NewRepoConfig returns a blank RepoConfig. 18 | func NewRepoConfig() *RepoConfig { 19 | return &RepoConfig{} 20 | } 21 | -------------------------------------------------------------------------------- /models/user_config.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | ) 6 | 7 | // UserConfig represents the values under users in the main admin config or the 8 | // contents of the config file in the user config repo. This type contains 9 | // values shared between the different config types. 10 | type UserConfig struct { 11 | Repos map[string]*RepoConfig `yaml:"repos"` 12 | Keys []PublicKey `yaml:"keys"` 13 | } 14 | 15 | // NewUserConfig returns a new, empty UserConfig. 16 | func NewUserConfig() *UserConfig { 17 | return &UserConfig{ 18 | Repos: make(map[string]*RepoConfig), 19 | } 20 | } 21 | 22 | // ParseUserConfig will return an UserConfig parsed from the given data. No 23 | // additional validation is done. 24 | func ParseUserConfig(data []byte) (*UserConfig, error) { 25 | uc := NewUserConfig() 26 | 27 | err := yaml.Unmarshal(data, uc) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return uc, nil 33 | } 34 | -------------------------------------------------------------------------------- /repo.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | // RepoType represents the different types of repositories that can be accessed. 11 | type RepoType int 12 | 13 | // RepoType defaults to RepoTypeAdmin to make sure that if this is improperly 14 | // set, the only way to access it is by being an admin. 15 | const ( 16 | RepoTypeAdmin RepoType = iota 17 | RepoTypeOrgConfig 18 | RepoTypeOrg 19 | RepoTypeUserConfig 20 | RepoTypeUser 21 | RepoTypeTopLevel 22 | ) 23 | 24 | // String implements Stringer. 25 | func (r RepoType) String() string { 26 | switch r { 27 | case RepoTypeAdmin: 28 | return "Admin" 29 | case RepoTypeOrgConfig: 30 | return "OrgConfig" 31 | case RepoTypeOrg: 32 | return "Org" 33 | case RepoTypeUserConfig: 34 | return "UserConfig" 35 | case RepoTypeUser: 36 | return "User" 37 | case RepoTypeTopLevel: 38 | return "TopLevel" 39 | default: 40 | return fmt.Sprintf("Unknown(%d)", r) 41 | } 42 | } 43 | 44 | // RepoLookup represents a repository that has been confirmed in the config and 45 | // the access level the given user has. 46 | type RepoLookup struct { 47 | Type RepoType 48 | PathParts []string 49 | Access AccessLevel 50 | } 51 | 52 | // Path returns the full path to this repository on disk. This is relative to 53 | // the gitdir root. 54 | func (repo RepoLookup) Path() string { 55 | switch repo.Type { 56 | case RepoTypeAdmin: 57 | return "admin/admin" 58 | case RepoTypeOrgConfig: 59 | return path.Join("admin", "org-"+repo.PathParts[0]) 60 | case RepoTypeOrg: 61 | return path.Join("orgs", repo.PathParts[0], repo.PathParts[1]) 62 | case RepoTypeUserConfig: 63 | return path.Join("admin", "user-"+repo.PathParts[0]) 64 | case RepoTypeUser: 65 | return path.Join("users", repo.PathParts[0], repo.PathParts[1]) 66 | case RepoTypeTopLevel: 67 | return path.Join("top-level", repo.PathParts[0]) 68 | } 69 | 70 | return "/dev/null" 71 | } 72 | 73 | // ErrInvalidRepoFormat is returned when a repo is looked up which cannot 74 | // exist based on the parsed format. 75 | var ErrInvalidRepoFormat = errors.New("invalid repo format") 76 | 77 | // ErrRepoDoesNotExist is returned when a repo is looked up which cannot exist 78 | // based on the config. 79 | var ErrRepoDoesNotExist = errors.New("repo does not exist") 80 | 81 | // LookupRepoAccess checks to see if path points to a valid repo and attaches 82 | // the access level this user has on that repository. 83 | func (c *Config) LookupRepoAccess(user *User, path string) (*RepoLookup, error) { 84 | repo, err := c.lookupRepo(path) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | repo.Access = c.checkUserRepoAccess(user, repo) 90 | 91 | return repo, nil 92 | } 93 | 94 | func (c *Config) lookupRepo(path string) (*RepoLookup, error) { 95 | // Chop off .git for looking up the repo 96 | path = strings.TrimSuffix(path, ".git") 97 | 98 | if path == "admin" { 99 | return &RepoLookup{ 100 | Type: RepoTypeAdmin, 101 | PathParts: []string{"admin", "admin"}, 102 | }, nil 103 | } 104 | 105 | if strings.HasPrefix(path, c.Options.OrgPrefix) { 106 | return c.lookupOrgRepo(strings.TrimPrefix(path, c.Options.OrgPrefix)) 107 | } 108 | 109 | if strings.HasPrefix(path, c.Options.UserPrefix) { 110 | return c.lookupUserRepo(strings.TrimPrefix(path, c.Options.UserPrefix)) 111 | } 112 | 113 | return c.lookupTopLevelRepo(path) 114 | } 115 | 116 | func (c *Config) lookupOrgRepo(path string) (*RepoLookup, error) { 117 | ret := &RepoLookup{ 118 | PathParts: strings.Split(path, "/"), 119 | } 120 | 121 | // If there was no org specified or it has more than 2 slashes, it's an 122 | // invalid repo. 123 | if len(ret.PathParts) == 0 || len(ret.PathParts) > 2 { 124 | return nil, ErrInvalidRepoFormat 125 | } 126 | 127 | // If the org doesn't exist, nobody has access. 128 | org, ok := c.Orgs[ret.PathParts[0]] 129 | if !ok { 130 | return nil, ErrRepoDoesNotExist 131 | } 132 | 133 | if len(ret.PathParts) == 1 { 134 | ret.Type = RepoTypeOrgConfig 135 | return ret, nil 136 | } 137 | 138 | // Past this point, it has to be an org repo. 139 | ret.Type = RepoTypeOrg 140 | 141 | // If implicit repos are enabled, it exists no matter what. 142 | if c.Options.ImplicitRepos { 143 | return ret, nil 144 | } 145 | 146 | // If the repo explicitly exists in the admin config, this repo exists. 147 | _, ok = org.Repos[ret.PathParts[1]] 148 | if ok { 149 | return ret, nil 150 | } 151 | 152 | return nil, ErrRepoDoesNotExist 153 | } 154 | 155 | func (c *Config) lookupUserRepo(path string) (*RepoLookup, error) { 156 | ret := &RepoLookup{ 157 | PathParts: strings.Split(path, "/"), 158 | } 159 | 160 | // If there was no user specified or it has more than 2 slashes, it's an 161 | // invalid repo. 162 | if len(ret.PathParts) == 0 || len(ret.PathParts) > 2 { 163 | return nil, ErrInvalidRepoFormat 164 | } 165 | 166 | // If the user doesn't exist, nobody has access. 167 | user, ok := c.Users[ret.PathParts[0]] 168 | if !ok { 169 | return nil, ErrRepoDoesNotExist 170 | } 171 | 172 | if len(ret.PathParts) == 1 { 173 | ret.Type = RepoTypeUserConfig 174 | return ret, nil 175 | } 176 | 177 | // Past this point, it has to be an org repo. 178 | ret.Type = RepoTypeUser 179 | 180 | // If implicit repos are enabled, it exists no matter what. 181 | if c.Options.ImplicitRepos { 182 | return ret, nil 183 | } 184 | 185 | // If the repo explicitly exists in the admin config, this repo exists. 186 | _, ok = user.Repos[ret.PathParts[1]] 187 | if ok { 188 | return ret, nil 189 | } 190 | 191 | return nil, ErrRepoDoesNotExist 192 | } 193 | 194 | func (c *Config) lookupTopLevelRepo(path string) (*RepoLookup, error) { 195 | repoPath := strings.Split(path, "/") 196 | if len(repoPath) != 1 { 197 | return nil, ErrInvalidRepoFormat 198 | } 199 | 200 | ret := &RepoLookup{ 201 | Type: RepoTypeTopLevel, 202 | PathParts: repoPath, 203 | } 204 | 205 | // If implicit repos are enabled, it exists no matter what. 206 | if c.Options.ImplicitRepos { 207 | return ret, nil 208 | } 209 | 210 | if _, ok := c.Repos[repoPath[0]]; ok { 211 | return ret, nil 212 | } 213 | 214 | return nil, ErrRepoDoesNotExist 215 | } 216 | -------------------------------------------------------------------------------- /repo_perms.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // AccessLevel represents the level of access being requested and the level of 11 | // access a user has. 12 | type AccessLevel int 13 | 14 | // AccessLevel defaults to AccessLevelNone for security. A repo lookup returns the 15 | // level of permissions a user has and if it's not explicitly set, they don't 16 | // have any. 17 | const ( 18 | AccessLevelNone AccessLevel = iota 19 | AccessLevelRead 20 | AccessLevelWrite 21 | AccessLevelAdmin 22 | ) 23 | 24 | // String implements Stringer. 25 | func (a AccessLevel) String() string { 26 | switch a { 27 | case AccessLevelNone: 28 | return "None" 29 | case AccessLevelRead: 30 | return "Read" 31 | case AccessLevelWrite: 32 | return "Write" 33 | case AccessLevelAdmin: 34 | return "Admin" 35 | default: 36 | return fmt.Sprintf("Unknown(%d)", a) 37 | } 38 | } 39 | 40 | const groupPrefix = "$" 41 | 42 | func (c *Config) doesGroupContainUser(username string, groupName string, groupPath []string) bool { 43 | // Group loop - this should never be possible in a checked config. 44 | if listContainsStr(groupPath, groupName) { 45 | log.Warn().Strs("groups", append(groupPath, groupName)).Msg("group loop") 46 | return false 47 | } 48 | 49 | groupPath = append(groupPath, groupName) 50 | 51 | for _, lookup := range c.Groups[groupName] { 52 | if strings.HasPrefix(lookup, groupPrefix) { 53 | intGroupName := strings.TrimPrefix(lookup, groupPrefix) 54 | 55 | if c.doesGroupContainUser(username, intGroupName, groupPath) { 56 | return true 57 | } 58 | } 59 | 60 | if lookup == username { 61 | return true 62 | } 63 | } 64 | 65 | return false 66 | } 67 | 68 | func (c *Config) checkListsForUser(username string, userLists ...[]string) bool { 69 | for _, list := range userLists { 70 | for _, lookup := range list { 71 | if strings.HasPrefix(lookup, groupPrefix) { 72 | if c.doesGroupContainUser(username, strings.TrimPrefix(lookup, groupPrefix), nil) { 73 | return true 74 | } 75 | } else { 76 | if lookup == username { 77 | return true 78 | } 79 | } 80 | } 81 | } 82 | 83 | return false 84 | } 85 | 86 | // TODO: clean up nolint here. 87 | func (c *Config) checkUserRepoAccess(user *User, repo *RepoLookup) AccessLevel { //nolint:cyclop,funlen 88 | // Admins always have access to everything. 89 | if user.IsAdmin { 90 | return AccessLevelAdmin 91 | } 92 | 93 | switch repo.Type { 94 | case RepoTypeAdmin: 95 | // If we made it this far, they're not an admin, so they don't have 96 | // access. 97 | return AccessLevelNone 98 | case RepoTypeOrgConfig: 99 | org := c.Orgs[repo.PathParts[0]] 100 | if c.checkListsForUser(user.Username, org.Admin) { 101 | return AccessLevelAdmin 102 | } 103 | 104 | return AccessLevelNone 105 | case RepoTypeOrg: 106 | org := c.Orgs[repo.PathParts[0]] 107 | 108 | // Because we already checked to see if this repo exists, this user has 109 | // admin on the repo if they're an org admin. 110 | if c.checkListsForUser(user.Username, org.Admin) { 111 | return AccessLevelAdmin 112 | } 113 | 114 | repo := org.Repos[repo.PathParts[1]] 115 | if repo == nil { 116 | // If this is an implicitly created repo, we can only check the org 117 | // level permissions. 118 | if c.Options.ImplicitRepos { 119 | switch { 120 | case c.checkListsForUser(user.Username, org.Write): 121 | return AccessLevelWrite 122 | case c.checkListsForUser(user.Username, org.Read): 123 | return AccessLevelRead 124 | } 125 | } 126 | 127 | return AccessLevelNone 128 | } 129 | 130 | switch { 131 | case c.checkListsForUser(user.Username, org.Write, repo.Write): 132 | return AccessLevelWrite 133 | case c.checkListsForUser(user.Username, org.Read, repo.Read): 134 | return AccessLevelRead 135 | } 136 | 137 | return AccessLevelNone 138 | case RepoTypeUserConfig: 139 | if repo.PathParts[0] == user.Username { 140 | return AccessLevelAdmin 141 | } 142 | 143 | return AccessLevelNone 144 | case RepoTypeUser: 145 | // Because we already checked to see if this repo exists, the user has 146 | // admin on the repo if they own the repo. 147 | if repo.PathParts[0] == user.Username { 148 | return AccessLevelAdmin 149 | } 150 | 151 | userConfig := c.Users[repo.PathParts[0]] 152 | repo := userConfig.Repos[repo.PathParts[1]] 153 | 154 | // Only the given user has access to implicit repos, so if the repo 155 | // isn't explicitly defined, noone else has access. 156 | if repo == nil { 157 | return AccessLevelNone 158 | } 159 | 160 | switch { 161 | case c.checkListsForUser(user.Username, repo.Write): 162 | return AccessLevelWrite 163 | case c.checkListsForUser(user.Username, repo.Read): 164 | return AccessLevelRead 165 | } 166 | case RepoTypeTopLevel: 167 | repo := c.Repos[repo.PathParts[0]] 168 | if repo == nil { 169 | // Only admins have access to implicitly created top-level repos. 170 | return AccessLevelNone 171 | } 172 | 173 | switch { 174 | case c.checkListsForUser(user.Username, repo.Write): 175 | return AccessLevelWrite 176 | case c.checkListsForUser(user.Username, repo.Read): 177 | return AccessLevelRead 178 | } 179 | } 180 | 181 | return AccessLevelNone 182 | } 183 | -------------------------------------------------------------------------------- /repo_test.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-git/go-billy/v5/memfs" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/belak/go-gitdir/models" 11 | ) 12 | 13 | func mustParsePK(data string) models.PublicKey { 14 | pk, err := models.ParsePublicKey([]byte(data)) 15 | if err != nil { 16 | panic(err.Error()) 17 | } 18 | 19 | return *pk 20 | } 21 | 22 | func newTestRepoConfig() *models.RepoConfig { 23 | repo := models.NewRepoConfig() 24 | repo.Write = []string{"write-user"} 25 | repo.Read = []string{"read-user"} 26 | 27 | return repo 28 | } 29 | 30 | func newTestConfig() *Config { //nolint:funlen 31 | c := NewConfig(memfs.New()) 32 | 33 | // Define some basic users 34 | c.Users["an-admin"] = models.NewAdminConfigUser() 35 | c.Users["an-admin"].IsAdmin = true 36 | c.Users["an-admin"].Keys = []models.PublicKey{ 37 | mustParsePK("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILQGpcX2owFW6hdTWHa/CzbTwhUJlmI8gKAgnp/c0NK2 an-admin"), 38 | } 39 | 40 | c.Users["non-admin"] = models.NewAdminConfigUser() 41 | c.Users["non-admin"].Repos["test-repo"] = newTestRepoConfig() 42 | 43 | // Org-level permissions 44 | c.Users["org-admin"] = models.NewAdminConfigUser() 45 | c.Users["org-write"] = models.NewAdminConfigUser() 46 | c.Users["org-read"] = models.NewAdminConfigUser() 47 | 48 | // Repo-level permissions 49 | c.Users["read-user"] = models.NewAdminConfigUser() 50 | c.Users["write-user"] = models.NewAdminConfigUser() 51 | c.Users["nothing-user"] = models.NewAdminConfigUser() 52 | 53 | c.Users["disabled"] = models.NewAdminConfigUser() 54 | c.Users["disabled"].Disabled = true 55 | c.Users["disabled"].Keys = []models.PublicKey{ 56 | mustParsePK("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBx4DYr9m+EnG0tgFsUIZqrDP7pa+vpVXJJ6/PE9J7Ll disabled"), 57 | } 58 | 59 | // Basic group 60 | c.Groups["admins"] = []string{"an-admin"} 61 | c.Groups["nested-admins"] = []string{"$admins"} 62 | 63 | // Group loop 64 | c.Groups["loop"] = []string{"$loop1"} 65 | c.Groups["loop1"] = []string{"$loop2"} 66 | c.Groups["loop2"] = []string{"$loop1"} 67 | 68 | // Basic org 69 | c.Orgs["an-org"] = models.NewOrgConfig() 70 | c.Orgs["an-org"].Admin = []string{"org-admin"} 71 | c.Orgs["an-org"].Write = []string{"org-write"} 72 | c.Orgs["an-org"].Read = []string{"org-read"} 73 | c.Orgs["an-org"].Repos["test-repo"] = newTestRepoConfig() 74 | 75 | c.Repos["test-repo"] = newTestRepoConfig() 76 | 77 | c.Invites["valid-invite"] = "an-admin" 78 | c.Invites["user-missing"] = "invalid-user" 79 | c.Invites["user-disabled"] = "disabled" 80 | 81 | // Force all settings repos to "on" 82 | c.Options.UserConfigRepos = true 83 | c.Options.OrgConfig = true 84 | c.Options.OrgConfigRepos = true 85 | 86 | c.flatten() 87 | 88 | // Insert a bogus PK which points to a user that doesn't exist. 89 | c.publicKeys["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQJzT5mM5eDYhoe3pVodWPCDzoj0/+pCVNoVsuUR4ao"] = "invalid-user" 90 | 91 | return c 92 | } 93 | 94 | func TestRepoTypeStringer(t *testing.T) { 95 | t.Parallel() 96 | 97 | assert.Equal(t, "Admin", RepoTypeAdmin.String()) 98 | assert.Equal(t, "OrgConfig", RepoTypeOrgConfig.String()) 99 | assert.Equal(t, "Org", RepoTypeOrg.String()) 100 | assert.Equal(t, "UserConfig", RepoTypeUserConfig.String()) 101 | assert.Equal(t, "User", RepoTypeUser.String()) 102 | assert.Equal(t, "TopLevel", RepoTypeTopLevel.String()) 103 | assert.Equal(t, "Unknown(42)", RepoType(42).String()) 104 | } 105 | 106 | func TestAccessLevelStringer(t *testing.T) { 107 | t.Parallel() 108 | 109 | assert.Equal(t, "None", AccessLevelNone.String()) 110 | assert.Equal(t, "Read", AccessLevelRead.String()) 111 | assert.Equal(t, "Write", AccessLevelWrite.String()) 112 | assert.Equal(t, "Admin", AccessLevelAdmin.String()) 113 | assert.Equal(t, "Unknown(42)", AccessLevel(42).String()) 114 | } 115 | 116 | func TestRepoLookup(t *testing.T) { //nolint:funlen 117 | t.Parallel() 118 | 119 | c := newTestConfig() 120 | 121 | var tests = []struct { //nolint:gofumpt 122 | Lookup string 123 | Type RepoType 124 | Path string 125 | Err error 126 | }{ 127 | { 128 | "admin", 129 | RepoTypeAdmin, 130 | "admin/admin", 131 | nil, 132 | }, 133 | { 134 | "@an-org", 135 | RepoTypeOrgConfig, 136 | "admin/org-an-org", 137 | nil, 138 | }, 139 | { 140 | "@an-org/test-repo", 141 | RepoTypeOrg, 142 | "orgs/an-org/test-repo", 143 | nil, 144 | }, 145 | { 146 | "~non-admin", 147 | RepoTypeUserConfig, 148 | "admin/user-non-admin", 149 | nil, 150 | }, 151 | { 152 | "~non-admin/test-repo", 153 | RepoTypeUser, 154 | "users/non-admin/test-repo", 155 | nil, 156 | }, 157 | { 158 | "test-repo", 159 | RepoTypeTopLevel, 160 | "top-level/test-repo", 161 | nil, 162 | }, 163 | 164 | { 165 | "@an-org/repo/invalid", 166 | RepoTypeOrg, 167 | "", 168 | ErrInvalidRepoFormat, 169 | }, 170 | { 171 | "@invalid-org", 172 | RepoTypeOrgConfig, 173 | "", 174 | ErrRepoDoesNotExist, 175 | }, 176 | { 177 | "@an-org/does-not-exist", 178 | RepoTypeOrg, 179 | "", 180 | ErrRepoDoesNotExist, 181 | }, 182 | 183 | { 184 | "~non-admin/repo/invalid", 185 | RepoTypeUser, 186 | "", 187 | ErrInvalidRepoFormat, 188 | }, 189 | { 190 | "~invalid-user", 191 | RepoTypeOrgConfig, 192 | "", 193 | ErrRepoDoesNotExist, 194 | }, 195 | { 196 | "~non-admin/does-not-exist", 197 | RepoTypeOrg, 198 | "", 199 | ErrRepoDoesNotExist, 200 | }, 201 | 202 | { 203 | "top-level/invalid", 204 | RepoTypeTopLevel, 205 | "", 206 | ErrInvalidRepoFormat, 207 | }, 208 | { 209 | "invalid-repo", 210 | RepoTypeTopLevel, 211 | "", 212 | ErrRepoDoesNotExist, 213 | }, 214 | } 215 | 216 | for _, test := range tests { 217 | lookup, err := c.lookupRepo(test.Lookup) 218 | 219 | if test.Err != nil { 220 | assert.Equal(t, test.Err, err) 221 | continue 222 | } 223 | 224 | require.Nil(t, err) 225 | 226 | assert.Equal(t, test.Type, lookup.Type) 227 | assert.Equal(t, test.Path, lookup.Path()) 228 | } 229 | 230 | invalidLookup := &RepoLookup{ 231 | Type: RepoType(42), 232 | } 233 | assert.Equal(t, "/dev/null", invalidLookup.Path()) 234 | } 235 | 236 | func TestDoesGroupContainUser(t *testing.T) { 237 | t.Parallel() 238 | 239 | c := newTestConfig() 240 | 241 | assert.True(t, c.doesGroupContainUser("an-admin", "admins", nil)) 242 | assert.True(t, c.doesGroupContainUser("an-admin", "nested-admins", nil)) 243 | assert.False(t, c.doesGroupContainUser("non-admin", "admins", nil)) 244 | assert.False(t, c.doesGroupContainUser("non-admin", "nested-admins", nil)) 245 | assert.False(t, c.doesGroupContainUser("an-admin", "loop", nil)) 246 | } 247 | 248 | func TestCheckListsForUser(t *testing.T) { 249 | t.Parallel() 250 | 251 | c := newTestConfig() 252 | 253 | // Basic checks 254 | assert.False(t, c.checkListsForUser("an-admin")) 255 | assert.True(t, c.checkListsForUser("an-admin", []string{"an-admin"})) 256 | assert.True(t, c.checkListsForUser("an-admin", []string{"$admins"})) 257 | assert.True(t, c.checkListsForUser("an-admin", []string{"$nested-admins"})) 258 | 259 | // Ensure loops don't crash this 260 | assert.False(t, c.checkListsForUser("an-admin", []string{"$loop"})) 261 | } 262 | 263 | type allRepoAccessLevels struct { 264 | Admin AccessLevel 265 | OrgConfig AccessLevel 266 | OrgRepo AccessLevel 267 | UserConfig AccessLevel 268 | UserRepo AccessLevel 269 | TopLevel AccessLevel 270 | } 271 | 272 | type allImplicitAccessLevels struct { 273 | Org AccessLevel 274 | User AccessLevel 275 | TopLevel AccessLevel 276 | } 277 | 278 | func lookupAndCheck(t *testing.T, c *Config, u *User, path string, access AccessLevel) { 279 | t.Helper() 280 | 281 | repo, err := c.LookupRepoAccess(u, path) 282 | require.Nil(t, err) 283 | require.NotNil(t, repo) 284 | assert.Equal(t, access, repo.Access) 285 | } 286 | 287 | func testCheckRepoAccess(t *testing.T, c *Config, u *User, access allRepoAccessLevels) { 288 | t.Helper() 289 | 290 | lookupAndCheck(t, c, u, "admin", access.Admin) 291 | lookupAndCheck(t, c, u, "@an-org", access.OrgConfig) 292 | lookupAndCheck(t, c, u, "@an-org/test-repo", access.OrgRepo) 293 | lookupAndCheck(t, c, u, "~non-admin", access.UserConfig) 294 | lookupAndCheck(t, c, u, "~non-admin/test-repo", access.UserRepo) 295 | lookupAndCheck(t, c, u, "test-repo", access.TopLevel) 296 | } 297 | 298 | func testImplicitRepoAccess(t *testing.T, c *Config, u *User, access allImplicitAccessLevels) { 299 | t.Helper() 300 | 301 | prevImplicit := c.Options.ImplicitRepos 302 | 303 | defer func() { 304 | c.Options.ImplicitRepos = prevImplicit 305 | }() 306 | 307 | c.Options.ImplicitRepos = true 308 | 309 | lookupAndCheck(t, c, u, "@an-org/implicit", access.Org) 310 | lookupAndCheck(t, c, u, "~non-admin/implicit", access.User) 311 | lookupAndCheck(t, c, u, "implicit", access.TopLevel) 312 | } 313 | 314 | func TestCheckUserRepoAccess(t *testing.T) { //nolint:funlen 315 | t.Parallel() 316 | 317 | c := newTestConfig() 318 | 319 | // Permission checking access level table 320 | // 321 | // |----------------+-------+------------+----------+-------------+-----------+-----------| 322 | // | | Admin | Org Config | Org Repo | User Config | User Repo | Top Level | 323 | // |----------------+-------+------------+----------+-------------+-----------+-----------| 324 | // | Admin | Admin | Admin | Admin | Admin | Admin | Admin | 325 | // | Org Admin | | Admin | Admin | | | | 326 | // | Org Writer | | | Write | | | | 327 | // | Org Reader | | | Read | | | | 328 | // | Non-Admin User | | | | Admin | Admin | | | 329 | // | Direct Writer | | | | | | Write | 330 | // | Direct Reader | | | | | | Read | 331 | // |----------------+-------+------------+----------+-------------+-----------+-----------| 332 | // 333 | // Implicit repos add the following: 334 | // 335 | // |---------------+-----------+-------+-------| 336 | // | | Top Level | Org | User | 337 | // |---------------+-----------+-------+-------| 338 | // | Admin | Admin | Admin | Admin | 339 | // | Org Admin | | Admin | | 340 | // | Org Writer | | Write | | 341 | // | Org Reader | | Read | | 342 | // | User | | | Admin | 343 | // | Direct Writer | | | | 344 | // | Direct Reader | | | | 345 | // |---------------+-----------+-------+-------| 346 | 347 | var tests = []struct { //nolint:gofumpt 348 | Username string 349 | Access allRepoAccessLevels 350 | ImplicitAccess allImplicitAccessLevels 351 | }{ 352 | { 353 | "an-admin", 354 | allRepoAccessLevels{ 355 | Admin: AccessLevelAdmin, 356 | OrgConfig: AccessLevelAdmin, 357 | OrgRepo: AccessLevelAdmin, 358 | UserConfig: AccessLevelAdmin, 359 | UserRepo: AccessLevelAdmin, 360 | TopLevel: AccessLevelAdmin, 361 | }, 362 | allImplicitAccessLevels{ 363 | Org: AccessLevelAdmin, 364 | User: AccessLevelAdmin, 365 | TopLevel: AccessLevelAdmin, 366 | }, 367 | }, 368 | { 369 | "org-admin", 370 | allRepoAccessLevels{ 371 | OrgConfig: AccessLevelAdmin, 372 | OrgRepo: AccessLevelAdmin, 373 | }, 374 | allImplicitAccessLevels{ 375 | Org: AccessLevelAdmin, 376 | }, 377 | }, 378 | { 379 | "org-write", 380 | allRepoAccessLevels{ 381 | OrgRepo: AccessLevelWrite, 382 | }, 383 | allImplicitAccessLevels{ 384 | Org: AccessLevelWrite, 385 | }, 386 | }, 387 | { 388 | "org-read", 389 | allRepoAccessLevels{ 390 | OrgRepo: AccessLevelRead, 391 | }, 392 | allImplicitAccessLevels{ 393 | Org: AccessLevelRead, 394 | }, 395 | }, 396 | { 397 | "non-admin", 398 | allRepoAccessLevels{ 399 | UserConfig: AccessLevelAdmin, 400 | UserRepo: AccessLevelAdmin, 401 | }, 402 | allImplicitAccessLevels{ 403 | User: AccessLevelAdmin, 404 | }, 405 | }, 406 | { 407 | "write-user", 408 | allRepoAccessLevels{ 409 | OrgRepo: AccessLevelWrite, 410 | UserRepo: AccessLevelWrite, 411 | TopLevel: AccessLevelWrite, 412 | }, 413 | allImplicitAccessLevels{}, 414 | }, 415 | { 416 | "read-user", 417 | allRepoAccessLevels{ 418 | OrgRepo: AccessLevelRead, 419 | UserRepo: AccessLevelRead, 420 | TopLevel: AccessLevelRead, 421 | }, 422 | allImplicitAccessLevels{}, 423 | }, 424 | { 425 | "nothing-user", 426 | allRepoAccessLevels{}, 427 | allImplicitAccessLevels{}, 428 | }, 429 | } 430 | 431 | for _, test := range tests { 432 | user, err := c.LookupUserFromUsername(test.Username) 433 | require.Nil(t, err) 434 | 435 | testCheckRepoAccess(t, c, user, test.Access) 436 | testImplicitRepoAccess(t, c, user, test.ImplicitAccess) 437 | } 438 | 439 | // One special test case to check a repo that doesn't exist. 440 | repo, err := c.LookupRepoAccess(&User{Username: "an-admin", IsAdmin: true}, "invalid-repo") 441 | require.Equal(t, ErrRepoDoesNotExist, err) 442 | require.Nil(t, repo) 443 | } 444 | -------------------------------------------------------------------------------- /ssh_commands.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "context" 5 | "path" 6 | "strings" 7 | 8 | "github.com/gliderlabs/ssh" 9 | 10 | "github.com/belak/go-gitdir/internal/git" 11 | ) 12 | 13 | func cmdWhoami(ctx context.Context, s ssh.Session, cmd []string) int { //nolint:interfacer 14 | user := CtxUser(ctx) 15 | _ = writeStringFmt(s, "logged in as %s\r\n", user.Username) 16 | 17 | return 0 18 | } 19 | 20 | func cmdNotFound(ctx context.Context, s ssh.Session, cmd []string) int { 21 | _ = writeStringFmt(s.Stderr(), "command %q not found\r\n", cmd[0]) 22 | return 1 23 | } 24 | 25 | func (serv *Server) cmdGitReceivePack(ctx context.Context, s ssh.Session, cmd []string) int { 26 | return serv.cmdRepoAction(ctx, s, cmd, AccessLevelWrite) 27 | } 28 | 29 | func (serv *Server) cmdGitUploadPack(ctx context.Context, s ssh.Session, cmd []string) int { 30 | return serv.cmdRepoAction(ctx, s, cmd, AccessLevelRead) 31 | } 32 | 33 | func (serv *Server) cmdRepoAction(ctx context.Context, s ssh.Session, cmd []string, access AccessLevel) int { 34 | if len(cmd) != 2 { 35 | _ = writeStringFmt(s.Stderr(), "Missing repo name argument\r\n") 36 | return 1 37 | } 38 | 39 | log, config, user := CtxExtract(ctx) 40 | pk := CtxPublicKey(ctx) 41 | 42 | // Sanitize the repo name 43 | // - Trim all slashes from beginning and end 44 | // - Add a root slash (so path.Clean works correctly) 45 | // - path.Clean 46 | // - Remove the initial slash 47 | // - Sanitize the name 48 | repoName := sanitize(path.Clean("/" + strings.Trim(cmd[1], "/"))[1:]) 49 | 50 | // Repo does not exist and permission checks should give the same error, so 51 | // information about what repos are defined is not leaked. 52 | repo, err := config.LookupRepoAccess(user, repoName) 53 | if err != nil { 54 | _ = writeStringFmt(s.Stderr(), "Repo does not exist\r\n") 55 | return -1 56 | } 57 | 58 | if repo.Access < access { 59 | _ = writeStringFmt(s.Stderr(), "Repo does not exist\r\n") 60 | return -1 61 | } 62 | 63 | // Because we check ImplicitRepos earlier, if they have admin access, it's 64 | // safe to ensure this repo exists. 65 | if repo.Access >= AccessLevelAdmin { 66 | _, err = git.EnsureRepo(serv.config.fs, repo.Path()) 67 | if err != nil { 68 | return -1 69 | } 70 | } 71 | 72 | returnCode := runCommand(log, serv.fs.Root(), s, []string{cmd[0], repo.Path()}, []string{ 73 | "GITDIR_BASE_DIR=" + serv.fs.Root(), 74 | "GITDIR_HOOK_REPO_PATH=" + repoName, 75 | "GITDIR_HOOK_PUBLIC_KEY=" + pk.String(), 76 | "GITDIR_LOG_FORMAT=console", 77 | }) 78 | 79 | // Reload the server config if a config repo was changed. 80 | if access == AccessLevelWrite { 81 | switch repo.Type { 82 | case RepoTypeAdmin, RepoTypeOrgConfig, RepoTypeUserConfig: 83 | err = serv.Reload() 84 | if err != nil { 85 | _ = writeStringFmt(s.Stderr(), "Error when reloading config: %s\r\n", err) 86 | } 87 | } 88 | } 89 | 90 | return returnCode 91 | } 92 | -------------------------------------------------------------------------------- /ssh_server.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync" 7 | 8 | "github.com/gliderlabs/ssh" 9 | billy "github.com/go-git/go-billy/v5" 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | gossh "golang.org/x/crypto/ssh" 13 | 14 | "github.com/belak/go-gitdir/models" 15 | ) 16 | 17 | // Server represents a gitdir server. 18 | type Server struct { 19 | lock *sync.RWMutex 20 | 21 | Addr string 22 | 23 | // Internal state 24 | log zerolog.Logger 25 | fs billy.Filesystem 26 | config *Config 27 | ssh *ssh.Server 28 | } 29 | 30 | // NewServer configures a new gitdir server and attempts to load the config 31 | // from the admin repo. 32 | func NewServer(fs billy.Filesystem) (*Server, error) { 33 | serv := &Server{ 34 | lock: &sync.RWMutex{}, 35 | log: log.Logger, 36 | fs: fs, 37 | } 38 | 39 | serv.ssh = &ssh.Server{ 40 | Handler: serv.handleSession, 41 | PublicKeyHandler: serv.handlePublicKey, 42 | } 43 | 44 | // This will set serv.settings 45 | if err := serv.Reload(); err != nil { 46 | return nil, err 47 | } 48 | 49 | return serv, nil 50 | } 51 | 52 | func (serv *Server) EnsureAdminUser(username string, pubKey *models.PublicKey) error { 53 | serv.lock.Lock() 54 | defer serv.lock.Unlock() 55 | 56 | // Create a new config object 57 | config := NewConfig(serv.fs) 58 | 59 | // Ensure the sample config 60 | err := config.EnsureConfig() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // Ensure the user exists 66 | err = config.EnsureAdminUser(username, pubKey) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | // Load the config from master 72 | err = config.Load() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return serv.reloadUnlocked(config) 78 | } 79 | 80 | // Reload reloads the server config in a thread-safe way. 81 | func (serv *Server) Reload() error { 82 | serv.lock.Lock() 83 | defer serv.lock.Unlock() 84 | 85 | // Create a new config object 86 | config := NewConfig(serv.fs) 87 | 88 | // Ensure the sample config 89 | err := config.EnsureConfig() 90 | if err != nil { 91 | return err 92 | } 93 | 94 | // Load the config from master 95 | err = config.Load() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return serv.reloadUnlocked(config) 101 | } 102 | 103 | func (serv *Server) reloadUnlocked(config *Config) error { 104 | serv.config = config 105 | 106 | // Load all ssh keys into the actual ssh server. 107 | for _, key := range serv.config.PrivateKeys { 108 | signer, err := gossh.NewSignerFromSigner(key) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | serv.ssh.AddHostKey(signer) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // Serve listens on the given listener for new SSH connections. 120 | func (serv *Server) Serve(l net.Listener) error { 121 | return serv.ssh.Serve(l) 122 | } 123 | 124 | // ListenAndServe listens on the Addr set on the server struct for new SSH 125 | // connections. 126 | func (serv *Server) ListenAndServe() error { 127 | serv.log.Info().Str("port", serv.Addr).Msg("Starting SSH server") 128 | 129 | // Because we're using ListenAndServe, we need to copy in the bind address. 130 | serv.ssh.Addr = serv.Addr 131 | 132 | return serv.ssh.ListenAndServe() 133 | } 134 | 135 | // GetAdminConfig returns the current admin config in a thread-safe manner. The 136 | // config should not be modified. 137 | func (serv *Server) GetAdminConfig() *Config { 138 | serv.lock.RLock() 139 | defer serv.lock.RUnlock() 140 | 141 | return serv.config 142 | } 143 | 144 | func (serv *Server) handlePublicKey(ctx ssh.Context, incomingKey ssh.PublicKey) bool { 145 | slog := CtxLogger(ctx).With(). 146 | Str("remote_user", ctx.User()). 147 | Str("remote_addr", ctx.RemoteAddr().String()).Logger() 148 | 149 | remoteUser := ctx.User() 150 | 151 | config := serv.GetAdminConfig() 152 | 153 | pk := models.PublicKey{PublicKey: incomingKey} 154 | 155 | /* 156 | if strings.HasPrefix(remoteUser, settings.Options.InvitePrefix) { 157 | invite := remoteUser[len(settings.Options.InvitePrefix):] 158 | 159 | // Try to accept the invite. If this fails, bail out. Otherwise, 160 | // continue looking up the user as normal. 161 | if ok := serv.AcceptInvite(invite, models.PublicKey{incomingKey, ""}); !ok { 162 | return false 163 | } 164 | 165 | // If it succeeded, we actually need to pull the refreshed admin config 166 | // so the new user shows up. 167 | settings = serv.GetAdminConfig() 168 | } 169 | */ 170 | 171 | user, err := config.LookupUserFromKey(pk, remoteUser) 172 | if err != nil { 173 | slog.Warn().Err(err).Msg("User not found") 174 | return false 175 | } 176 | 177 | // Update the context with what we discovered 178 | CtxSetUser(ctx, user) 179 | CtxSetConfig(ctx, config) 180 | CtxSetLogger(ctx, &slog) 181 | CtxSetPublicKey(ctx, &pk) 182 | 183 | return true 184 | } 185 | 186 | func (serv *Server) handleSession(s ssh.Session) { 187 | var ctx context.Context = s.Context() 188 | 189 | // Pull a logger for the session 190 | slog := CtxLogger(ctx) 191 | 192 | defer func() { 193 | // Note that we can't pass in slog as an argument because that would 194 | // result in the value getting captured and we want to be able to 195 | // annotate this with new values. 196 | handlePanic(slog) 197 | }() 198 | 199 | slog.Info().Msg("Starting session") 200 | defer slog.Info().Msg("Session closed") 201 | 202 | cmd := s.Command() 203 | 204 | // If the user doesn't provide any arguments, we want to run the internal 205 | // whoami command. 206 | if len(cmd) == 0 { 207 | cmd = []string{"whoami"} 208 | } 209 | 210 | // Add the command to the logger 211 | tmpLog := slog.With().Str("cmd", cmd[0]).Logger() 212 | slog = &tmpLog 213 | ctx = WithLogger(ctx, slog) 214 | 215 | var exit int 216 | 217 | switch cmd[0] { 218 | case "whoami": 219 | exit = cmdWhoami(ctx, s, cmd) 220 | case "git-receive-pack": 221 | exit = serv.cmdGitReceivePack(ctx, s, cmd) 222 | case "git-upload-pack": 223 | exit = serv.cmdGitUploadPack(ctx, s, cmd) 224 | default: 225 | exit = cmdNotFound(ctx, s, cmd) 226 | } 227 | 228 | slog.Info().Int("return_code", exit).Msg("Return code") 229 | _ = s.Exit(exit) 230 | } 231 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/rs/zerolog/log" 7 | 8 | "github.com/belak/go-gitdir/models" 9 | ) 10 | 11 | // User is the internal representation of a user. This data is copied from the 12 | // loaded config file. 13 | type User struct { 14 | Username string 15 | IsAnonymous bool 16 | IsAdmin bool 17 | } 18 | 19 | // AnonymousUser is the user that is returned when no user is available. 20 | var AnonymousUser = &User{ 21 | Username: "", 22 | IsAnonymous: true, 23 | IsAdmin: false, 24 | } 25 | 26 | // ErrUserNotFound is returned from LookupUser commands when the user is not 27 | // found. 28 | var ErrUserNotFound = errors.New("user not found") 29 | 30 | // LookupUserFromUsername looks up a user objects given their username. 31 | func (c *Config) LookupUserFromUsername(username string) (*User, error) { 32 | userConfig, ok := c.Users[username] 33 | if !ok { 34 | log.Warn().Msg("username does not match a user") 35 | return AnonymousUser, ErrUserNotFound 36 | } 37 | 38 | if userConfig.Disabled { 39 | log.Warn().Msg("user is disabled") 40 | return AnonymousUser, ErrUserNotFound 41 | } 42 | 43 | return &User{ 44 | Username: username, 45 | IsAnonymous: false, 46 | IsAdmin: userConfig.IsAdmin, 47 | }, nil 48 | } 49 | 50 | // LookupUserFromKey looks up a user object given their PublicKey. 51 | func (c *Config) LookupUserFromKey(pk models.PublicKey, remoteUser string) (*User, error) { 52 | username, ok := c.publicKeys[pk.RawMarshalAuthorizedKey()] 53 | if !ok { 54 | log.Warn().Msg("key does not exist") 55 | return AnonymousUser, ErrUserNotFound 56 | } 57 | 58 | userConfig, ok := c.Users[username] 59 | if !ok { 60 | log.Warn().Msg("key does not match a user") 61 | return AnonymousUser, ErrUserNotFound 62 | } 63 | 64 | if userConfig.Disabled { 65 | log.Warn().Msg("user is disabled") 66 | return AnonymousUser, ErrUserNotFound 67 | } 68 | 69 | // If they weren't the git user make sure their username matches their key. 70 | if remoteUser != c.Options.GitUser && remoteUser != username { 71 | log.Warn().Msg("key belongs to different user") 72 | return AnonymousUser, ErrUserNotFound 73 | } 74 | 75 | return &User{ 76 | Username: username, 77 | IsAnonymous: false, 78 | IsAdmin: userConfig.IsAdmin, 79 | }, nil 80 | } 81 | 82 | // LookupUserFromInvite looks up a user object given an invite code. 83 | func (c *Config) LookupUserFromInvite(invite string) (*User, error) { 84 | username, ok := c.Invites[invite] 85 | if !ok { 86 | log.Warn().Msg("invite does not exist") 87 | return AnonymousUser, ErrUserNotFound 88 | } 89 | 90 | userConfig, ok := c.Users[username] 91 | if !ok { 92 | log.Warn().Msg("invite does not match a user") 93 | return AnonymousUser, ErrUserNotFound 94 | } 95 | 96 | if userConfig.Disabled { 97 | log.Warn().Msg("user is disabled") 98 | return AnonymousUser, ErrUserNotFound 99 | } 100 | 101 | return &User{ 102 | Username: username, 103 | IsAnonymous: false, 104 | IsAdmin: userConfig.IsAdmin, 105 | }, nil 106 | } 107 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/belak/go-gitdir/models" 10 | ) 11 | 12 | func TestLookupUserFromUsername(t *testing.T) { 13 | t.Parallel() 14 | 15 | c := newTestConfig() 16 | 17 | var tests = []struct { //nolint:gofumpt 18 | Username string 19 | Error error 20 | }{ 21 | { 22 | "missing-user", 23 | ErrUserNotFound, 24 | }, 25 | { 26 | "disabled", 27 | ErrUserNotFound, 28 | }, 29 | } 30 | 31 | for _, test := range tests { 32 | user, err := c.LookupUserFromUsername(test.Username) 33 | 34 | if test.Error != nil { 35 | require.Equal(t, test.Error, err) 36 | } else { 37 | assert.Nil(t, err) 38 | assert.Equal(t, test.Username, user.Username) 39 | } 40 | } 41 | } 42 | 43 | func TestLookupUserFromKey(t *testing.T) { //nolint:funlen 44 | t.Parallel() 45 | 46 | c := newTestConfig() 47 | 48 | var tests = []struct { //nolint:gofumpt 49 | UserHint string 50 | Username string 51 | PublicKey string 52 | Error error 53 | ErrorGitUser error 54 | }{ 55 | { 56 | "missing-user", 57 | "missing-user", 58 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ7+BNW+C5HHQ8C3QcJCYfUvxz+biXbxB0JtufT+P2AD user-not-found", 59 | ErrUserNotFound, 60 | ErrUserNotFound, 61 | }, 62 | { 63 | "disabled", 64 | "disabled", 65 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBx4DYr9m+EnG0tgFsUIZqrDP7pa+vpVXJJ6/PE9J7Ll disabled", 66 | ErrUserNotFound, 67 | ErrUserNotFound, 68 | }, 69 | { 70 | "invalid-user", 71 | "invalid-user", 72 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQJzT5mM5eDYhoe3pVodWPCDzoj0/+pCVNoVsuUR4ao invalid-user", 73 | ErrUserNotFound, 74 | ErrUserNotFound, 75 | }, 76 | 77 | { 78 | "an-admin", 79 | "an-admin", 80 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILQGpcX2owFW6hdTWHa/CzbTwhUJlmI8gKAgnp/c0NK2 an-admin", 81 | nil, 82 | nil, 83 | }, 84 | { 85 | // Mismatched username will error 86 | "non-admin", 87 | "an-admin", 88 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILQGpcX2owFW6hdTWHa/CzbTwhUJlmI8gKAgnp/c0NK2 an-admin", 89 | ErrUserNotFound, 90 | nil, 91 | }, 92 | } 93 | 94 | for _, test := range tests { 95 | pk, err := models.ParsePublicKey([]byte(test.PublicKey)) 96 | require.Nil(t, err) 97 | 98 | // Try with the username hint 99 | user, err := c.LookupUserFromKey(*pk, test.UserHint) 100 | 101 | if test.Error != nil { 102 | require.Equal(t, test.Error, err) 103 | } else { 104 | assert.Nil(t, err) 105 | assert.Equal(t, test.Username, user.Username) 106 | } 107 | 108 | // Try without the username hint 109 | user, err = c.LookupUserFromKey(*pk, c.Options.GitUser) 110 | 111 | if test.ErrorGitUser != nil { 112 | require.Equal(t, test.ErrorGitUser, err) 113 | } else { 114 | assert.Nil(t, err) 115 | assert.Equal(t, test.Username, user.Username) 116 | } 117 | } 118 | } 119 | 120 | func TestLookupUserFromInvite(t *testing.T) { 121 | t.Parallel() 122 | 123 | c := newTestConfig() 124 | 125 | var tests = []struct { //nolint:gofumpt 126 | Username string 127 | Invite string 128 | Error error 129 | }{ 130 | { 131 | "an-admin", 132 | "valid-invite", 133 | nil, 134 | }, 135 | { 136 | "an-admin", 137 | "invalid-invite", 138 | ErrUserNotFound, 139 | }, 140 | { 141 | "disabled", 142 | "user-disabled", 143 | ErrUserNotFound, 144 | }, 145 | { 146 | "invalid-user", 147 | "user-missing", 148 | ErrUserNotFound, 149 | }, 150 | } 151 | 152 | for _, test := range tests { 153 | user, err := c.LookupUserFromInvite(test.Invite) 154 | 155 | if test.Error != nil { 156 | require.Equal(t, test.Error, err) 157 | } else { 158 | assert.Nil(t, err) 159 | assert.Equal(t, test.Username, user.Username) 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/gliderlabs/ssh" 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | type multiError struct { 18 | errors []error 19 | } 20 | 21 | func newMultiError(errors ...error) error { 22 | var ret multiError 23 | 24 | for _, err := range errors { 25 | if err != nil { 26 | ret.errors = append(ret.errors, err) 27 | } 28 | } 29 | 30 | if len(ret.errors) == 0 { 31 | return nil 32 | } 33 | 34 | return &ret 35 | } 36 | 37 | func (ce *multiError) Error() string { 38 | buf := bytes.NewBuffer(nil) 39 | 40 | for _, err := range ce.errors { 41 | buf.WriteString("- ") 42 | buf.WriteString(err.Error()) 43 | buf.WriteString("\n") 44 | } 45 | 46 | return buf.String() 47 | } 48 | 49 | func listContainsStr(list []string, target string) bool { 50 | for _, val := range list { 51 | if val == target { 52 | return true 53 | } 54 | } 55 | 56 | return false 57 | } 58 | 59 | func handlePanic(logger *zerolog.Logger) { 60 | if r := recover(); r != nil { 61 | logger.Error().Err(fmt.Errorf("%s", r)).Msg("Caught panic") 62 | } 63 | } 64 | 65 | func writeStringFmt(w io.Writer, format string, args ...interface{}) error { 66 | _, err := io.WriteString(w, fmt.Sprintf(format, args...)) 67 | return err 68 | } 69 | 70 | func getExitStatusFromError(err error) int { 71 | if err == nil { 72 | return 0 73 | } 74 | 75 | var exitErr *exec.ExitError 76 | if !errors.As(err, &exitErr) { 77 | return 1 78 | } 79 | 80 | return exitErr.ProcessState.ExitCode() 81 | } 82 | 83 | func sanitize(in string) string { 84 | // TODO: this should do more 85 | return strings.ToLower(in) 86 | } 87 | 88 | // TODO: see if this can be cleaned up. 89 | func runCommand( //nolint:funlen 90 | log *zerolog.Logger, 91 | cwd string, 92 | session ssh.Session, 93 | args []string, 94 | environ []string, 95 | ) int { 96 | // NOTE: we are explicitly ignoring gosec here because we *only* pass in 97 | // known commands here. 98 | cmd := exec.Command(args[0], args[1:]...) //nolint:gosec 99 | cmd.Dir = cwd 100 | 101 | cmd.Env = append(cmd.Env, environ...) 102 | cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH")) 103 | 104 | stdin, err := cmd.StdinPipe() 105 | if err != nil { 106 | log.Error().Err(err).Msg("Failed to get stdin pipe") 107 | return 1 108 | } 109 | 110 | stdout, err := cmd.StdoutPipe() 111 | if err != nil { 112 | log.Error().Err(err).Msg("Failed to get stdout pipe") 113 | return 1 114 | } 115 | 116 | stderr, err := cmd.StderrPipe() 117 | if err != nil { 118 | log.Error().Err(err).Msg("Failed to get stderr pipe") 119 | return 1 120 | } 121 | 122 | wg := &sync.WaitGroup{} 123 | wg.Add(2) 124 | 125 | if err = cmd.Start(); err != nil { 126 | log.Error().Err(err).Msg("Failed to start command") 127 | return 1 128 | } 129 | 130 | go func() { 131 | defer stdin.Close() 132 | 133 | if _, stdinErr := io.Copy(stdin, session); stdinErr != nil { 134 | log.Error().Err(err).Msg("Failed to write session to stdin") 135 | } 136 | }() 137 | 138 | go func() { 139 | defer wg.Done() 140 | 141 | if _, stdoutErr := io.Copy(session, stdout); stdoutErr != nil { 142 | log.Error().Err(err).Msg("Failed to write stdout to session") 143 | } 144 | }() 145 | 146 | go func() { 147 | defer wg.Done() 148 | 149 | if _, stderrErr := io.Copy(session.Stderr(), stderr); stderrErr != nil { 150 | log.Error().Err(err).Msg("Failed to write stderr to session") 151 | } 152 | }() 153 | 154 | // Ensure all the output has been written before we wait on the command to 155 | // exit. 156 | wg.Wait() 157 | 158 | // Wait for the command to exit and log any errors we get 159 | err = cmd.Wait() 160 | if err != nil { 161 | log.Error().Err(err).Msg("Failed to wait for command exit") 162 | } 163 | 164 | return getExitStatusFromError(err) 165 | } 166 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package gitdir 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os/exec" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestNewMultiError(t *testing.T) { 13 | t.Parallel() 14 | 15 | err := newMultiError() 16 | assert.Nil(t, err) 17 | 18 | err = newMultiError(nil) 19 | assert.Nil(t, err) 20 | 21 | err = newMultiError( 22 | errors.New("test error please ignore"), 23 | ) 24 | assert.NotNil(t, err) 25 | assert.Equal(t, "- test error please ignore\n", err.Error()) 26 | 27 | err = newMultiError( 28 | nil, 29 | errors.New("test error please ignore"), 30 | nil, 31 | ) 32 | assert.NotNil(t, err) 33 | assert.Equal(t, "- test error please ignore\n", err.Error()) 34 | 35 | err = newMultiError( 36 | nil, 37 | errors.New("test error please ignore"), 38 | nil, 39 | errors.New("test error please ignore as well"), 40 | ) 41 | assert.NotNil(t, err) 42 | assert.Equal(t, "- test error please ignore\n- test error please ignore as well\n", err.Error()) 43 | } 44 | 45 | func TestListContainsStr(t *testing.T) { 46 | t.Skip("not implemented") 47 | 48 | t.Parallel() 49 | } 50 | 51 | func TestHandlePanic(t *testing.T) { 52 | t.Skip("not implemented") 53 | 54 | t.Parallel() 55 | } 56 | 57 | func TestWriteStringFmt(t *testing.T) { 58 | t.Parallel() 59 | 60 | buf := bytes.NewBuffer(nil) 61 | 62 | err := writeStringFmt(buf, "hello %s", "world") 63 | assert.Nil(t, err) 64 | assert.Equal(t, "hello world", buf.String()) 65 | } 66 | 67 | func TestGetExitStatusFromError(t *testing.T) { 68 | t.Parallel() 69 | 70 | // It is way harder than it should be to mock out an ExitError, so we just 71 | // run a command we know will return a valid ExitError. 72 | cmd := exec.Command("sh", "-c", "exit 10") 73 | cmdErr := cmd.Run() 74 | 75 | var tests = []struct { //nolint:gofumpt 76 | Input error 77 | Expected int 78 | }{ 79 | { 80 | nil, 81 | 0, 82 | }, 83 | { 84 | errors.New("non ExitError"), 85 | 1, 86 | }, 87 | { 88 | cmdErr, 89 | 10, 90 | }, 91 | } 92 | 93 | for _, test := range tests { 94 | output := getExitStatusFromError(test.Input) 95 | assert.Equal(t, test.Expected, output) 96 | } 97 | } 98 | 99 | func TestSanitize(t *testing.T) { 100 | t.Parallel() 101 | 102 | var tests = []struct { //nolint:gofumpt 103 | Input string 104 | Expected string 105 | }{ 106 | {"", ""}, 107 | {"HELLO-WORLD", "hello-world"}, 108 | } 109 | 110 | for _, test := range tests { 111 | output := sanitize(test.Input) 112 | assert.Equal(t, test.Expected, output) 113 | } 114 | } 115 | 116 | func TestRunCommand(t *testing.T) { 117 | t.Skip("not implemented") 118 | 119 | t.Parallel() 120 | } 121 | --------------------------------------------------------------------------------