├── .gitmodules ├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── pr-dependabot.yml │ ├── codeql-analysis.yml │ ├── pr-docker.yml │ ├── pr-build.yml │ └── release.yml ├── main.go ├── types ├── credentials.go ├── exec-credentials.go ├── token.go └── key.go ├── ldap ├── options.go └── ldap.go ├── cmd ├── reset.go ├── authentication.go ├── main.go └── server.go ├── client ├── cache.go ├── client.go └── interactive.go ├── .gitignore ├── Dockerfile ├── scripts ├── post-checkout └── pre-commit ├── version ├── version.go └── version_test.go ├── server ├── errors.go ├── options.go ├── middlewares │ └── log.go └── server.go ├── go.mod ├── CHANGELOG.md ├── Makefile ├── CODE_OF_CONDUCT.md ├── README.md ├── LICENSE └── go.sum /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | /deploy 3 | k8s-ldap-auth* 4 | /.env* 5 | /distribution 6 | dist/ -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - vbouchaud 3 | custom: 4 | - https://paypal.me/vbouchaud 5 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "k8s-ldap-auth/cmd" 5 | ) 6 | 7 | func main() { 8 | if err := cmd.Start(); err != nil { 9 | panic(err) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/credentials.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Credentials struct { 4 | Username string `json:"username"` 5 | Password string `json:"password"` 6 | } 7 | 8 | func (c *Credentials) IsValid() bool { 9 | return len(c.Username) != 0 && len(c.Password) != 0 10 | } 11 | -------------------------------------------------------------------------------- /ldap/options.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | const ( 4 | ScopeBaseObject = "base" 5 | ScopeSingleLevel = "single" 6 | ScopeWholeSubtree = "sub" 7 | ) 8 | 9 | var scopeMap = map[string]int{ 10 | ScopeBaseObject: 0, 11 | ScopeSingleLevel: 1, 12 | ScopeWholeSubtree: 2, 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | commit-message: 9 | prefix: "gomod" 10 | - package-ecosystem: "docker" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | commit-message: 15 | prefix: "docker" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | commit-message: 21 | prefix: "action" 22 | -------------------------------------------------------------------------------- /cmd/reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "k8s-ldap-auth/client" 9 | ) 10 | 11 | func getResetCmd() *cli.Command { 12 | return &cli.Command{ 13 | Name: "reset", 14 | Aliases: []string{"r"}, 15 | Usage: "delete the cached ExecCredential to force authentication at next invocation", 16 | HideHelp: false, 17 | Action: func(c *cli.Context) error { 18 | // ignore error 19 | os.Remove(client.GetCacheFilePath()) 20 | 21 | return nil 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /client/cache.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | 8 | "github.com/adrg/xdg" 9 | ) 10 | 11 | func GetCacheDirPath() string { 12 | dir := path.Join(xdg.CacheHome, "k8s-ldap-auth") 13 | return dir 14 | } 15 | 16 | func GetCacheFilePath() string { 17 | file := path.Join(GetCacheDirPath(), "token") 18 | return file 19 | } 20 | 21 | func getCachedToken() []byte { 22 | token, err := ioutil.ReadFile(GetCacheFilePath()) 23 | if err != nil { 24 | return nil 25 | } 26 | 27 | return token 28 | } 29 | 30 | func refreshCache(data []byte) error { 31 | if err := os.MkdirAll(GetCacheDirPath(), 0700); err != nil { 32 | return err 33 | } 34 | 35 | if err := ioutil.WriteFile(GetCacheFilePath(), data, 0600); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Client version [e.g. 22] 29 | - Server version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | 10 | # Packages # 11 | ############ 12 | # it's better to unpack these files and commit the raw source 13 | # git has its own built in compression methods 14 | *.7z 15 | *.dmg 16 | *.gz 17 | *.iso 18 | *.jar 19 | *.rar 20 | *.tar 21 | *.zip 22 | 23 | # Logs and databases # 24 | ###################### 25 | *.log 26 | *.sql 27 | *.sqlite 28 | 29 | # OS generated files # 30 | ###################### 31 | .DS_Store 32 | .DS_Store? 33 | ._* 34 | .Spotlight-V100 35 | .Trashes 36 | ehthumbs.db 37 | Thumbs.db 38 | 39 | # Certifiate/Key pairs # 40 | ######################## 41 | **/*.pem 42 | **/*.csr 43 | 44 | # Dependencies # 45 | ################ 46 | vendor 47 | coverage.out 48 | 49 | # Custom # 50 | ########## 51 | /k8s-ldap-auth* 52 | /.env* 53 | /tr.json 54 | /deploy 55 | dist/ 56 | 57 | # idea 58 | /.idea -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.5-alpine AS build 2 | # TODO: dynamically get this value 3 | ENV GOVERSION=1.21.4 4 | 5 | WORKDIR /usr/src 6 | RUN apk add --no-cache \ 7 | gcc=12.2.1_git20220924-r10 \ 8 | build-base=0.5-r3 9 | 10 | ARG PKG 11 | ARG APPNAME 12 | ARG COMMITHASH 13 | ARG BUILDTIME 14 | ARG VERSION 15 | 16 | COPY go.mod ./ 17 | COPY go.sum ./ 18 | 19 | RUN go mod download 20 | 21 | COPY . ./ 22 | 23 | RUN CGO_ENABLED=0 go build \ 24 | -a \ 25 | -o k8s-ldap-auth \ 26 | -ldflags "\ 27 | -X ${PKG}/version.APPNAME=${APPNAME} \ 28 | -X ${PKG}/version.VERSION=${VERSION} \ 29 | -X ${PKG}/version.GOVERSION=${GOVERSION} \ 30 | -X ${PKG}/version.BUILDTIME=${BUILDTIME} \ 31 | -X ${PKG}/version.COMMITHASH=${COMMITHASH}" \ 32 | main.go 33 | 34 | FROM gcr.io/distroless/static:nonroot 35 | EXPOSE 3000 36 | WORKDIR / 37 | COPY --from=build /usr/src/k8s-ldap-auth . 38 | USER 65532:65532 39 | 40 | ENTRYPOINT [ "/k8s-ldap-auth" ] 41 | -------------------------------------------------------------------------------- /.github/workflows/pr-dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependabot auto-approve 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'hopopops/k8s-ldap-auth' 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | - name: Approve a PR 20 | run: gh pr review --approve "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | - name: Enable auto-merge for Dependabot PRs 25 | run: gh pr merge --auto --rebase "$PR_URL" 26 | env: 27 | PR_URL: ${{github.event.pull_request.html_url}} 28 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | -------------------------------------------------------------------------------- /scripts/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RED=$(tput -Txterm setaf 1) 4 | NORMAL="$(tput sgr0)" 5 | 6 | PREV_COMMIT=$1 7 | POST_COMMIT=$2 8 | 9 | if [[ '0000000000000000000000000000000000000000' == "$PREV_COMMIT" ]]; then exit 0; fi 10 | 11 | # Function used to cjeck if a file changed between two commits, if changed it prints a message to tell the user to reinstall the vendors (using $2) 12 | check() { 13 | DIFF=$(git diff --shortstat "$PREV_COMMIT..$POST_COMMIT" ${1}) 14 | if [[ $DIFF != "" ]]; then 15 | echo -e "$RED ${1} has changed. You must run ${2} install$NORMAL" 16 | fi 17 | } 18 | 19 | # Search for pakage files and check if they changed, for node related files use yarn, for go use make 20 | for f in $(find . -not -path "*node_modules*" -name 'package.json'); do check $f "yarn"; done 21 | for f in $(find . -not -path "*node_modules*" -name 'yarn.lock'); do check $f "yarn"; done 22 | for f in $(find . -not -path "*node_modules*" -name 'go.mod'); do check $f "make"; done 23 | for f in $(find . -not -path "*node_modules*" -name 'go.sum'); do check $f "make"; done 24 | -------------------------------------------------------------------------------- /types/exec-credentials.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | client "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" 8 | ) 9 | 10 | type ExecCredential struct { 11 | Kind string `json:"kind"` 12 | APIVersion string `json:"apiVersion,omitempty"` 13 | Spec client.ExecCredentialSpec `json:"spec"` 14 | Status *client.ExecCredentialStatus `json:"status,omitempty"` 15 | } 16 | 17 | func NewExecCredential(APIVersion string) ExecCredential { 18 | ec := ExecCredential{ 19 | Kind: "ExecCredential", 20 | } 21 | 22 | if APIVersion != "" { 23 | ec.APIVersion = APIVersion 24 | } else { 25 | ec.APIVersion = os.Getenv("AUTH_API_VERSION") 26 | } 27 | 28 | return ec 29 | } 30 | 31 | func (ec *ExecCredential) Marshal(APIVersion string) ([]byte, error) { 32 | ec.Kind = "ExecCredential" 33 | 34 | if APIVersion != "" { 35 | ec.APIVersion = APIVersion 36 | } else { 37 | ec.APIVersion = os.Getenv("AUTH_API_VERSION") 38 | } 39 | 40 | data, err := json.Marshal(ec) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return data, nil 46 | } 47 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // APPNAME is the app-global application name string, which should be substituted with a real value during build. 9 | var APPNAME = "UNKNOWN" 10 | 11 | // VERSION is the app-global version string, which should be substituted with a real value during build. 12 | var VERSION = "UNKNOWN" 13 | 14 | // GOVERSION is the Golang version used to generate the binary. 15 | var GOVERSION = "UNKNOWN" 16 | 17 | // BUILDTIME is the timestamp at which the binary was created. 18 | var BUILDTIME = "UNKNOWN" 19 | 20 | // COMMITHASH is the git commit hash that was used to generate the binary. 21 | var COMMITHASH = "UNKNOWN" 22 | 23 | // Version of the application 24 | func Version() string { 25 | return fmt.Sprintf("%s %s (commit %s built with go %s the %s)", APPNAME, VERSION, COMMITHASH, GOVERSION, BUILDTIME) 26 | } 27 | 28 | // Compiled swill transform the BUILDTIME constant into a valid time.Time (defaults to time.Now()) 29 | func Compiled() time.Time { 30 | if BUILDTIME == "UNKNOWN" { 31 | return time.Now() 32 | } 33 | 34 | t, err := time.Parse("2006-02-15T15:04:05Z+00:00", BUILDTIME) 35 | if err != nil { 36 | return time.Now() 37 | } 38 | 39 | return t 40 | } 41 | -------------------------------------------------------------------------------- /server/errors.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | ) 7 | 8 | type ServerError struct { 9 | e error 10 | s int 11 | } 12 | 13 | var ( 14 | ErrServerError = &ServerError{ 15 | e: errors.New(http.StatusText(http.StatusInternalServerError)), 16 | s: http.StatusInternalServerError, 17 | } 18 | // ErrNotAcceptable means that the request is not acceptable because of it's content-type, language or encoding 19 | ErrNotAcceptable = &ServerError{ 20 | e: errors.New(http.StatusText(http.StatusNotAcceptable)), 21 | s: http.StatusNotAcceptable, 22 | } 23 | // ErrDecodeFailed means the request body could not be decoded 24 | ErrDecodeFailed = &ServerError{ 25 | e: errors.New("Failed Decoding Request Body"), 26 | s: http.StatusBadRequest, 27 | } 28 | // ErrMalformedCredentials 29 | ErrMalformedCredentials = &ServerError{ 30 | e: errors.New("Malformed Credential Object"), 31 | s: http.StatusBadRequest, 32 | } 33 | // ErrMalformedToken 34 | ErrMalformedToken = &ServerError{ 35 | e: errors.New("Malformed Token Object"), 36 | s: http.StatusBadRequest, 37 | } 38 | // ErrUnauthorized 39 | ErrUnauthorized = &ServerError{ 40 | e: errors.New(http.StatusText(http.StatusUnauthorized)), 41 | s: http.StatusUnauthorized, 42 | } 43 | // ErrForbidden 44 | ErrForbidden = &ServerError{ 45 | e: errors.New(http.StatusText(http.StatusForbidden)), 46 | s: http.StatusForbidden, 47 | } 48 | ) 49 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL" 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: '15 5 * * 3' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'go' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v6 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v4 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 36 | # If this step fails, then you should remove it and run the build manually (see below) 37 | - name: Autobuild 38 | uses: github/codeql-action/autobuild@v4 39 | 40 | # ℹ️ Command-line programs to run using the OS shell. 41 | # 📚 https://git.io/JvXDl 42 | 43 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 44 | # and modify them (or add more) to build your code if your project 45 | # uses a compiled language 46 | 47 | #- run: | 48 | # make bootstrap 49 | # make release 50 | 51 | - name: Perform CodeQL Analysis 52 | uses: github/codeql-action/analyze@v4 53 | -------------------------------------------------------------------------------- /cmd/authentication.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/adrg/xdg" 8 | "github.com/urfave/cli/v2" 9 | 10 | "k8s-ldap-auth/client" 11 | ) 12 | 13 | func getAuthenticationCmd() *cli.Command { 14 | passwordFile := path.Join(xdg.ConfigHome, "k8s-ldap-auth", "password") 15 | 16 | return &cli.Command{ 17 | Name: "authenticate", 18 | Aliases: []string{"a", "auth"}, 19 | Usage: "perform an authentication through a /auth endpoint", 20 | HideHelp: false, 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "endpoint", 24 | Required: true, 25 | Usage: "The full `URI` the client will authenticate against.", 26 | }, 27 | &cli.StringFlag{ 28 | Name: "user", 29 | EnvVars: []string{"USER"}, 30 | Usage: "The `USER` the client will connect as.", 31 | }, 32 | &cli.StringFlag{ 33 | Name: "password", 34 | EnvVars: []string{"PASSWORD"}, 35 | FilePath: passwordFile, 36 | Usage: "The `PASSWORD` the client will connect with, can be located in '" + passwordFile + "'.", 37 | }, 38 | }, 39 | Action: func(c *cli.Context) error { 40 | var ( 41 | addr = c.String("endpoint") 42 | username = c.String("user") 43 | password = c.String("password") 44 | ) 45 | 46 | err := client.Auth(addr, username, password) 47 | if err != nil { 48 | return fmt.Errorf("There was an error authenticating, %w", err) 49 | } 50 | 51 | return nil 52 | }, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | "github.com/urfave/cli/v2" 10 | 11 | "k8s-ldap-auth/version" 12 | ) 13 | 14 | type action func(*cli.Context) error 15 | 16 | // Start the cmd application 17 | func Start() error { 18 | cli.VersionPrinter = func(c *cli.Context) { 19 | fmt.Println(version.Version()) 20 | } 21 | 22 | app := cli.NewApp() 23 | app.Name = "k8s-ldap-auth" 24 | app.Usage = "A client/server for kubernetes webhook authentication." 25 | app.Version = version.VERSION 26 | app.Compiled = version.Compiled() 27 | app.Authors = []*cli.Author{ 28 | { 29 | Name: "Vianney Bouchaud", 30 | Email: "vianney@hopopops.org", 31 | }, 32 | } 33 | 34 | app.UseShortOptionHandling = true 35 | app.EnableBashCompletion = true 36 | 37 | app.Flags = []cli.Flag{ 38 | &cli.IntFlag{ 39 | Name: "verbose", 40 | Value: int(zerolog.ErrorLevel), 41 | EnvVars: []string{"VERBOSE"}, 42 | Usage: "The verbosity `LEVEL` - (rs/zerolog#Level values).", 43 | }, 44 | } 45 | 46 | app.Before = func(c *cli.Context) error { 47 | var ( 48 | verbose = zerolog.Level(c.Int("verbose")) 49 | ) 50 | 51 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 52 | zerolog.SetGlobalLevel(verbose) 53 | 54 | if verbose < zerolog.InfoLevel { 55 | log.Logger = log.With().Logger() 56 | } 57 | 58 | return nil 59 | } 60 | 61 | app.Commands = []*cli.Command{ 62 | getServerCmd(), 63 | getAuthenticationCmd(), 64 | getResetCmd(), 65 | } 66 | 67 | return app.Run(os.Args) 68 | } 69 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MODULE=$(cat "$PWD/go.mod" | head -n1 | sed -r -E "s/module (.*)/\1/") 4 | RED=$(tput -Txterm setaf 1) 5 | GREEN=$(tput -Txterm setaf 2) 6 | UNDERLINE="$(tput smul)" 7 | NORMAL="$(tput sgr0)" 8 | BOLD="$(tput bold)" 9 | STATUS=0 10 | 11 | title() { 12 | echo "${UNDERLINE}${BOLD}${1}${NORMAL}" 13 | } 14 | 15 | success() { 16 | echo "${GREEN}✓${NORMAL} ${1}" 17 | } 18 | 19 | error() { 20 | echo "${RED}✗${NORMAL} $1" 21 | STATUS=1 22 | } 23 | 24 | NAME=$(git branch | sed 's/* //') 25 | if [ "$NAME" != '(no branch)' ]; then 26 | # Get all changes (files & their package) 27 | packages=("") 28 | for file in $(git diff --diff-filter=d --relative --cached --name-only | grep -E '\.(go)$'); do 29 | packages+=("$(dirname "$file")") 30 | done 31 | 32 | # Remove duplicate packages 33 | packages=($(printf "%s\n" "${packages[@]}" | sort -u | tr '\n' ' ')) 34 | 35 | # Format changed files 36 | title "Format" 37 | for file in $(git diff --diff-filter=d --cached --name-only | grep -E '\.(go)$'); do 38 | { 39 | gofmt -s -w "${file}" >>/dev/null 2>&1 && success "${file}" && git add "${file}" 40 | } || { 41 | error "Failed to format ${file}" 42 | } 43 | done 44 | 45 | # Vet changed packages 46 | title "Lint" 47 | for package in "${packages[@]}"; do 48 | { 49 | go vet "${MODULE}/${package}" && success "${MODULE}/${package}" 50 | } || { 51 | error "Failed to lint ${MODULE}/${package}" 52 | } 53 | done 54 | fi 55 | 56 | if [ $STATUS -ne 0 ]; then 57 | echo 58 | echo "Hook ${BOLD}${RED}failed${NORMAL}, please check your code." 59 | echo 60 | exit 1 61 | else 62 | echo 63 | echo "Hook ${BOLD}${GREEN}succeeded${NORMAL}, proceeding to commit." 64 | echo 65 | fi 66 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/rs/zerolog/log" 10 | 11 | "k8s-ldap-auth/types" 12 | ) 13 | 14 | func Auth(addr, user, pass string) error { 15 | var ( 16 | err error 17 | token []byte 18 | ec types.ExecCredential 19 | ) 20 | 21 | token = getCachedToken() 22 | 23 | log.Info().Str("KUBERNETES_EXEC_INFO", os.Getenv("KUBERNETES_EXEC_INFO")).Msg("Testing variable of interest.") 24 | 25 | err = json.Unmarshal(token, &ec) 26 | if err != nil || ec.Status.ExpirationTimestamp.Time.Unix() < time.Now().Unix() { 27 | if err != nil { 28 | log.Warn().Err(err).Send() 29 | } else { 30 | log.Warn().Int64("expirationTimestamp", ec.Status.ExpirationTimestamp.Time.Unix()).Msg("ExecCredential expired.") 31 | } 32 | 33 | token, err = performAuth(addr, user, pass) 34 | if err != nil { 35 | log.Error().Err(err).Msg("Could not perform authentication.") 36 | return err 37 | } 38 | log.Info().Msg("Got credentials") 39 | log.Debug().RawJSON("token", token).Send() 40 | 41 | err = json.Unmarshal(token, &ec) 42 | if err != nil { 43 | log.Error().Err(err).Msg("Could not parse token.") 44 | return err 45 | } 46 | 47 | log.Info().Msg("Token parsed successfully") 48 | } 49 | 50 | token, err = ec.Marshal("") 51 | if err != nil { 52 | log.Error().Err(err).Msg("Could not marshal ExecCredential.") 53 | } 54 | 55 | if shouldCache := os.Getenv("KUBERNETES_AUTHENTICATION_CACHE"); shouldCache != "false" && shouldCache != "0" { 56 | err = refreshCache(token) 57 | if err != nil { 58 | log.Error().Err(err).Msg("Could not refresh cache.") 59 | } 60 | } 61 | 62 | log.Info().Msg("End of authentication.") 63 | fmt.Printf("%s\n", token) 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /server/options.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/rsa" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/rs/zerolog/log" 8 | 9 | "k8s-ldap-auth/ldap" 10 | "k8s-ldap-auth/server/middlewares" 11 | "k8s-ldap-auth/types" 12 | ) 13 | 14 | // Option function for configuring a server instance 15 | type Option func(*Instance) error 16 | 17 | // WithLdap bind a ldap object to a server instance 18 | func WithLdap( 19 | ldapURL, 20 | bindDN, 21 | bindPassword, 22 | searchBase, 23 | searchScope, 24 | searchFilter, 25 | memberofProperty, 26 | usernameProperty string, 27 | extraAttributes []string) Option { 28 | return func(i *Instance) error { 29 | i.l = ldap.NewInstance( 30 | ldapURL, 31 | bindDN, 32 | bindPassword, 33 | searchBase, 34 | searchScope, 35 | searchFilter, 36 | memberofProperty, 37 | usernameProperty, 38 | extraAttributes, 39 | append(extraAttributes, memberofProperty, usernameProperty), 40 | ) 41 | 42 | return nil 43 | } 44 | } 45 | 46 | // WithMiddleware will bind the given middleware function to the root of the router 47 | func WithMiddleware(m mux.MiddlewareFunc) Option { 48 | return func(i *Instance) error { 49 | i.m = append(i.m, m) 50 | 51 | return nil 52 | } 53 | } 54 | 55 | // WithAccessLogs add an access log middleware to the server 56 | func WithAccessLogs() Option { 57 | return WithMiddleware(middlewares.AccessLog) 58 | } 59 | 60 | // WithLdap bind a ldap object to a server instance 61 | func WithKey(privateKeyFile, publicKeyFile string) Option { 62 | return func(i *Instance) error { 63 | var ( 64 | key *rsa.PrivateKey 65 | err error 66 | ) 67 | 68 | if privateKeyFile != "" && publicKeyFile != "" { 69 | log.Info().Msg("privateKeyFile and publicKeyFile were provided, loading key.") 70 | key, err = types.LoadKey(privateKeyFile, publicKeyFile) 71 | } else { 72 | log.Info().Msg("No key provided, generating a new one.") 73 | key, err = types.GenerateKey() 74 | } 75 | 76 | i.k = key 77 | 78 | return err 79 | } 80 | } 81 | 82 | // WithLdap bind a ldap object to a server instance 83 | func WithTTL(ttl int64) Option { 84 | return func(i *Instance) error { 85 | i.ttl = ttl 86 | 87 | return nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/pr-docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Review Dockerfile Changes 3 | 4 | on: 5 | pull_request: 6 | paths: 7 | - Dockerfile 8 | 9 | jobs: 10 | hadolint: 11 | name: Review Dockerfile 12 | runs-on: ubuntu-latest 13 | 14 | if: ${{ github.actor != 'dependabot[bot]' }} 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v6 19 | 20 | - name: Hadolint 21 | uses: reviewdog/action-hadolint@v1 22 | with: 23 | github_token: ${{ secrets.github_token }} 24 | reporter: github-pr-review 25 | 26 | docker: 27 | name: Build 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v6 32 | - name: Prepare 33 | id: prep 34 | run: |- 35 | echo ::set-output name=buildtime::$(date -u +'%FT%TZ%:z') 36 | echo ::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ') 37 | 38 | - name: Set up QEMU 39 | uses: docker/setup-qemu-action@v1 40 | 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | 44 | - name: Build 45 | id: docker_build 46 | uses: docker/build-push-action@v2 47 | with: 48 | context: . 49 | file: ./Dockerfile 50 | platforms: linux/amd64,linux/arm/v7,linux/arm64 51 | push: false 52 | tags: ${{ github.event.repository.name }}:latest 53 | build-args: |- 54 | APPNAME=${{ secrets.APP_NAME }} 55 | PKG=${{ github.repository }} 56 | VERSION=latest 57 | COMMITHASH=${{ github.sha }} 58 | BUILDTIME=${{ steps.prep.outputs.buildtime }} 59 | labels: |- 60 | org.opencontainers.image.title=${{ github.event.repository.name }} 61 | org.opencontainers.image.description=${{ github.event.repository.description }} 62 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 63 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 64 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 65 | org.opencontainers.image.revision=${{ github.sha }} 66 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 67 | -------------------------------------------------------------------------------- /.github/workflows/pr-build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Review Code Changes 3 | 4 | on: 5 | pull_request: 6 | paths: 7 | - cmd/**.go 8 | - client/**.go 9 | - server/**.go 10 | - ldap/**.go 11 | - server/**.go 12 | - version/**.go 13 | - types/**.go 14 | - go.mod 15 | - go.sum 16 | - main.go 17 | 18 | jobs: 19 | build: 20 | name: Build project 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | go: 25 | - "1.21" 26 | 27 | if: ${{ github.actor != 'dependabot[bot]' }} 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v6 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v6 35 | with: 36 | go-version: ${{ matrix.go }} 37 | 38 | - name: Prepare 39 | id: prep 40 | run: |- 41 | echo ::set-output name=buildtime::$(date -u +'%FT%TZ%:z') 42 | echo ::set-output name=go-version::$(go version | sed -r 's/go version go(.+)\s.+/\1/') 43 | 44 | - name: Cache 45 | id: cache 46 | uses: actions/cache@v2 47 | with: 48 | path: |- 49 | ~/.cache/go-build 50 | ~/go/pkg/mod 51 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 52 | restore-keys: |- 53 | ${{ runner.os }}-go- 54 | 55 | - name: Install Dependencies 56 | if: steps.cache.outputs.cache-hit != 'true' 57 | run: |- 58 | go mod download 59 | 60 | - name: Build 61 | run: |- 62 | go build \ 63 | -trimpath \ 64 | -buildmode=pie \ 65 | -mod=readonly \ 66 | -modcacherw \ 67 | -o ${{ secrets.APP_NAME }} \ 68 | -ldflags "\ 69 | -X ${{ github.repository }}/version.APPNAME=${{ secrets.APP_NAME }} \ 70 | -X ${{ github.repository }}/version.VERSION=latest \ 71 | -X ${{ github.repository }}/version.GOVERSION=${{ steps.prep.outputs.go-version }} \ 72 | -X ${{ github.repository }}/version.BUILDTIME=${{ steps.prep.outputs.buildtime }} \ 73 | -X ${{ github.repository }}/version.COMMITHASH=${{ github.sha }} \ 74 | -s -w" 75 | 76 | - name: Verify 77 | run: |- 78 | ./${{ secrets.APP_NAME }} --version 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module k8s-ldap-auth 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/adrg/xdg v0.5.3 9 | github.com/etherlabsio/healthcheck/v2 v2.0.0 10 | github.com/go-ldap/ldap/v3 v3.4.12 11 | github.com/gorilla/mux v1.8.1 12 | github.com/lestrrat-go/jwx v1.2.31 13 | github.com/mattn/go-isatty v0.0.20 14 | github.com/rs/zerolog v1.34.0 15 | github.com/urfave/cli/v2 v2.27.7 16 | github.com/zalando/go-keyring v0.2.6 17 | golang.org/x/term v0.38.0 18 | k8s.io/api v0.34.3 19 | k8s.io/apimachinery v0.34.3 20 | k8s.io/client-go v0.34.3 21 | ) 22 | 23 | require ( 24 | al.essio.dev/pkg/shellescape v1.5.1 // indirect 25 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 26 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 27 | github.com/danieljoos/wincred v1.2.2 // indirect 28 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 29 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 30 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect 31 | github.com/go-logr/logr v1.4.2 // indirect 32 | github.com/goccy/go-json v0.10.3 // indirect 33 | github.com/godbus/dbus/v5 v5.1.0 // indirect 34 | github.com/gogo/protobuf v1.3.2 // indirect 35 | github.com/google/uuid v1.6.0 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/kr/text v0.2.0 // indirect 38 | github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect 39 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 40 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 41 | github.com/lestrrat-go/iter v1.0.2 // indirect 42 | github.com/lestrrat-go/option v1.0.1 // indirect 43 | github.com/mattn/go-colorable v0.1.13 // indirect 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 45 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 46 | github.com/pkg/errors v0.9.1 // indirect 47 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 48 | github.com/x448/float16 v0.8.4 // indirect 49 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 50 | go.yaml.in/yaml/v2 v2.4.2 // indirect 51 | golang.org/x/crypto v0.45.0 // indirect 52 | golang.org/x/net v0.47.0 // indirect 53 | golang.org/x/sys v0.39.0 // indirect 54 | golang.org/x/text v0.31.0 // indirect 55 | gopkg.in/inf.v0 v0.9.1 // indirect 56 | k8s.io/klog/v2 v2.130.1 // indirect 57 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 58 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 59 | sigs.k8s.io/randfill v1.0.0 // indirect 60 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /types/token.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "crypto/rsa" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/lestrrat-go/jwx/jwa" 11 | "github.com/lestrrat-go/jwx/jwt" 12 | "github.com/rs/zerolog/log" 13 | 14 | auth "k8s.io/api/authentication/v1" 15 | ) 16 | 17 | type Token struct { 18 | token jwt.Token 19 | } 20 | 21 | func NewToken(user *auth.UserInfo, ttl int64) (*Token, error) { 22 | now := time.Now() 23 | 24 | data, err := json.Marshal(user) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | t := jwt.New() 30 | t.Set(jwt.IssuedAtKey, now.Unix()) 31 | t.Set(jwt.ExpirationKey, now.Add(time.Duration(ttl)*time.Second).Unix()) 32 | t.Set("user", data) 33 | 34 | token := &Token{ 35 | token: t, 36 | } 37 | 38 | return token, nil 39 | } 40 | 41 | func Parse(payload []byte, key *rsa.PrivateKey) (*Token, error) { 42 | t, err := jwt.Parse( 43 | payload, 44 | jwt.WithVerify(jwa.RS256, &key.PublicKey), 45 | jwt.WithValidate(true), 46 | ) 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | token := &Token{ 53 | token: t, 54 | } 55 | 56 | return token, nil 57 | } 58 | 59 | func (t *Token) GetUser() (*auth.UserInfo, error) { 60 | if v, ok := t.token.Get("user"); ok { 61 | var user auth.UserInfo 62 | 63 | log.Debug().Str("data", fmt.Sprintf("%v", v)).Msg("Got user data.") 64 | 65 | data, err := base64.StdEncoding.WithPadding(base64.NoPadding).DecodeString(fmt.Sprintf("%v", v)) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | err = json.Unmarshal(data, &user) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &user, nil 76 | } 77 | 78 | return nil, fmt.Errorf("Could not get user attribute of jwt token") 79 | } 80 | 81 | func (t *Token) IsValid() bool { 82 | exp, err := t.Expiration() 83 | 84 | if err != nil { 85 | log.Debug().Str("err", err.Error()).Msg("token validation") 86 | } else { 87 | log.Debug().Str("exp", exp.String()).Bool("stillvalid", time.Now().Unix() < exp.Unix()).Msg("token validation") 88 | } 89 | 90 | return err == nil && time.Now().Unix() < exp.Unix() 91 | } 92 | 93 | func (t *Token) Expiration() (time.Time, error) { 94 | if v, ok := t.token.Get(jwt.ExpirationKey); ok { 95 | return v.(time.Time), nil 96 | } 97 | 98 | return time.Time{}, fmt.Errorf("Could not get jwt expiration time") 99 | } 100 | 101 | func (t *Token) Payload(key *rsa.PrivateKey) ([]byte, error) { 102 | signed, err := jwt.Sign(t.token, jwa.RS256, key) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | return signed, nil 108 | } 109 | -------------------------------------------------------------------------------- /server/middlewares/log.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // ProxyResponseWriter is a workaround for getting HTTP Response information in the access logs 11 | // With the default ResponseWriter interface we only have methods to write the status code and parts of 12 | // the response content. But there's no properties to tell the current status of the response. 13 | // This implementation proxy the default ResponseWriter methods to the usual one, but keeps the 14 | // current state of the request in it's properties 15 | type ProxyResponseWriter struct { 16 | code int 17 | length int 18 | parent http.ResponseWriter 19 | } 20 | 21 | // NewProxyResponseWriter create a new ProxyResponseWriter that wrap API calls to another ResponseWriter 22 | func NewProxyResponseWriter(parent http.ResponseWriter) *ProxyResponseWriter { 23 | return &ProxyResponseWriter{ 24 | code: 200, 25 | length: 0, 26 | parent: parent, 27 | } 28 | } 29 | 30 | // Header return the inner ResponseWriter Header 31 | func (brs *ProxyResponseWriter) Header() http.Header { 32 | return brs.parent.Header() 33 | } 34 | 35 | // Write a portion of the response content to the inner ResponseWriter, and keep track of the byte length added 36 | func (brs *ProxyResponseWriter) Write(content []byte) (int, error) { 37 | length, err := brs.parent.Write(content) 38 | brs.length += length 39 | return length, err 40 | } 41 | 42 | // WriteHeader to the inner ResponseWriter, and keep track of the current response status 43 | func (brs *ProxyResponseWriter) WriteHeader(code int) { 44 | brs.code = code 45 | brs.parent.WriteHeader(code) 46 | } 47 | 48 | // AccessLog provide an HTTP server middleware for logging access to the server 49 | // It follows tha common Apache Access Log format, except for the %l and %u values 50 | // that are not implemented yet (%l should probably be ignore anymay). 51 | // You can find more information about this format here : https://httpd.apache.org/docs/2.4/logs.html 52 | func AccessLog(next http.Handler) http.Handler { 53 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 54 | received := time.Now() 55 | wrapper := NewProxyResponseWriter(res) 56 | next.ServeHTTP(wrapper, req) 57 | elapsed := time.Now().Sub(received) 58 | 59 | log.Info(). 60 | Str("remoteaddr", req.RemoteAddr). 61 | Str("method", req.Method). 62 | Str("url", req.URL.String()). 63 | Str("proto", req.Proto). 64 | Int("code", wrapper.code). 65 | Int("length", wrapper.length). 66 | Str("referer", req.Header.Get("Referer")). 67 | Str("useragent", req.Header.Get("User-Agent")). 68 | Int64("elapsed", elapsed.Microseconds()). 69 | Send() 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | // "reflect" 5 | "testing" 6 | // "time" 7 | ) 8 | 9 | func None() func() { 10 | return func() {} 11 | } 12 | 13 | func Mock() func() { 14 | return func() { 15 | APPNAME = "pipeline" 16 | VERSION = "0.1.0" 17 | GOVERSION = "1.13" 18 | BUILDTIME = "2019-10-11T07:34:49Z+00:00" 19 | COMMITHASH = "13754adcbf" 20 | } 21 | } 22 | 23 | func MockInvalidBuildTime() func() { 24 | return func() { 25 | APPNAME = "pipeline" 26 | VERSION = "0.1.0" 27 | GOVERSION = "1.13" 28 | BUILDTIME = "not-10-a 09:da:te+02:00" 29 | COMMITHASH = "13754adcbf" 30 | } 31 | } 32 | 33 | func Reset() func() { 34 | return func() { 35 | APPNAME = "UNKNOWN" 36 | VERSION = "UNKNOWN" 37 | GOVERSION = "UNKNOWN" 38 | BUILDTIME = "UNKNOWN" 39 | COMMITHASH = "UNKNOWN" 40 | } 41 | } 42 | 43 | func TestVersion(t *testing.T) { 44 | tests := []struct { 45 | name string 46 | want string 47 | before func() 48 | after func() 49 | }{ 50 | { 51 | name: "Unset variables", 52 | want: "UNKNOWN UNKNOWN (commit UNKNOWN built with go UNKNOWN the UNKNOWN)", 53 | before: None(), 54 | after: None(), 55 | }, 56 | { 57 | name: "With variables", 58 | want: "pipeline 0.1.0 (commit 13754adcbf built with go 1.13 the 2019-10-11T07:34:49Z+00:00)", 59 | before: Mock(), 60 | after: Reset(), 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | tt.before() 67 | if got := Version(); got != tt.want { 68 | t.Errorf("Version() = %v, want %v", got, tt.want) 69 | } 70 | tt.after() 71 | }) 72 | } 73 | } 74 | 75 | // func TestCompiled(t *testing.T) { 76 | // now := time.Now() 77 | // expected, err := time.Parse("2006-02-15T15:04:05Z+00:00", "2019-10-11T07:34:49Z+00:00") 78 | // if err != nil { 79 | // t.Errorf("Failed to parse expected compiled date, %s", err) 80 | // } 81 | 82 | // tests := []struct { 83 | // name string 84 | // want time.Time 85 | // before func() 86 | // after func() 87 | // }{ 88 | // { 89 | // name: "Unset variables", 90 | // want: now, 91 | // before: None(), 92 | // after: None(), 93 | // }, 94 | // { 95 | // name: "With invalid buildtime", 96 | // want: now, 97 | // before: MockInvalidBuildTime(), 98 | // after: Reset(), 99 | // }, 100 | // { 101 | // name: "With valid buildtime", 102 | // want: expected, 103 | // before: Mock(), 104 | // after: Reset(), 105 | // }, 106 | // } 107 | 108 | // for _, tt := range tests { 109 | // t.Run(tt.name, func(t *testing.T) { 110 | // tt.before() 111 | // if got := Compiled(); !reflect.DeepEqual(got, tt.want) { 112 | // t.Errorf("Compiled() = %v, want %v", got, tt.want) 113 | // } 114 | // tt.after() 115 | // }) 116 | // } 117 | // } 118 | -------------------------------------------------------------------------------- /types/key.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "errors" 9 | "io/ioutil" 10 | 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | func GenerateKey() (*rsa.PrivateKey, error) { 15 | key, err := rsa.GenerateKey(rand.Reader, 2048) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return key, nil 21 | } 22 | 23 | var ( 24 | ErrPrivKeyNotFound = errors.New("No RSA private key found") 25 | ErrPrivKeyNotReadable = errors.New("Unable to parse private key") 26 | ErrPubKeyNotFound = errors.New("No RSA private key found") 27 | ErrPubKeyNotReadable = errors.New("Unable to parse public key") 28 | ) 29 | 30 | // The following is heavily inspired from https://gist.github.com/jshap70/259a87a7146393aab5819873a193b88c 31 | func LoadKey(rsaPrivateKeyLocation, rsaPublicKeyLocation string) (*rsa.PrivateKey, error) { 32 | priv, err := ioutil.ReadFile(rsaPrivateKeyLocation) 33 | if err != nil { 34 | log.Error().Msg("Private key file was not found.") 35 | return nil, ErrPrivKeyNotFound 36 | } 37 | 38 | privPem, _ := pem.Decode(priv) 39 | var privPemBytes []byte 40 | if privPem.Type != "RSA PRIVATE KEY" { 41 | log.Warn().Str("pem_type", privPem.Type).Msg("RSA private key has the wrong type") 42 | } 43 | privPemBytes = privPem.Bytes 44 | 45 | var parsedKey interface{} 46 | if parsedKey, err = x509.ParsePKCS1PrivateKey(privPemBytes); err != nil { 47 | log.Error().Err(err).Msg("Could not parse to PKCS1 key.") 48 | if parsedKey, err = x509.ParsePKCS8PrivateKey(privPemBytes); err != nil { // note this returns type `interface{}` 49 | log.Error().Err(err).Msg("Could not parse to PKCS8 key.") 50 | return nil, ErrPrivKeyNotReadable 51 | } 52 | } 53 | 54 | var privateKey *rsa.PrivateKey 55 | var ok bool 56 | privateKey, ok = parsedKey.(*rsa.PrivateKey) 57 | if !ok { 58 | log.Error().Msg("Could not parse to PKCS8 key.") 59 | return nil, ErrPrivKeyNotReadable 60 | } 61 | 62 | pub, err := ioutil.ReadFile(rsaPublicKeyLocation) 63 | if err != nil { 64 | log.Error().Msg("Public key file was not found.") 65 | return nil, ErrPubKeyNotFound 66 | } 67 | 68 | pubPem, _ := pem.Decode(pub) 69 | if pubPem == nil { 70 | log.Error().Msg("Could not decode pem public key.") 71 | return nil, ErrPubKeyNotReadable 72 | } 73 | 74 | if pubPem.Type != "PUBLIC KEY" { 75 | log.Error().Str("pem_type", pubPem.Type).Msg("Public key has the wrong type.") 76 | return nil, ErrPubKeyNotReadable 77 | } 78 | 79 | if parsedKey, err = x509.ParsePKIXPublicKey(pubPem.Bytes); err != nil { 80 | log.Error().Err(err).Msg("Could not parse to PKIX public key.") 81 | return nil, ErrPubKeyNotReadable 82 | } 83 | 84 | var pubKey *rsa.PublicKey 85 | if pubKey, ok = parsedKey.(*rsa.PublicKey); !ok { 86 | log.Error().Err(err).Msg("Could not parse public key to rsa.") 87 | return nil, ErrPubKeyNotReadable 88 | } 89 | 90 | privateKey.PublicKey = *pubKey 91 | 92 | return privateKey, nil 93 | } 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [4.0.0] - 2022-03-29 11 | ### Common 12 | #### Modified 13 | - Made a bunch of dep updates, all through dependabot so full list will be easy to get (`git log v3.2.1..HEAD --author=dependabot --pretty=oneline`). 14 | 15 | ### Server 16 | #### Modified 17 | - Changing `--search-attributes` to `--extra-attributes`, fixing extra attributes not being processed and added to the user object. This is the change triggering a major update since the interface changed. 18 | 19 | ## [3.2.1] - 2021-11-10 20 | ### Client 21 | #### Modified 22 | - The entry in the credential manager is now specific to the k8s-ldap-auth server address, allowing for different k8s-ldap-auth to be used against different ldap servers. 23 | 24 | ## [3.2.0] - 2021-10-29 25 | ### Client 26 | #### Added 27 | - Password is now stored into the OS credential manager upon successful interactive authentication. 28 | 29 | ## [3.1.0] - 2021-08-19 30 | ### Server 31 | #### Added 32 | - `/health` now serves the application status. 33 | 34 | ## [3.0.0] - 2021-08-17 35 | ### Server 36 | #### Added 37 | - User username properties mapping with ldap can now be set with specific parameters or environment variable. 38 | 39 | #### Changed 40 | - Parameter `--member-of-property` is now `--memberof-property` (style consistency change) 41 | - TokenReview user won't contain a list of group cn only anymore but their full dn to prevent name collision 42 | 43 | ### Client 44 | #### Changed 45 | - Cache file and folder containing the ExecCredential are now only readable by the owner. 46 | 47 | ## [2.0.1] - 2021-07-27 48 | ### Common 49 | #### Added 50 | - Added PIE compilation for binary hardening. 51 | - Added trimpath option for reproducible builds. 52 | 53 | ## [2.0.0] - 2021-07-26 54 | ### Server 55 | #### Added 56 | - Token longevity can now be configured (in seconds). Default to 43200 (12 hours). 57 | 58 | #### Changed 59 | - Token generated now only contains uid. Groups and DN are added to the TokenReview when kube-apiserver dial k8s-ldap-auth. 60 | 61 | ### Client 62 | #### Added 63 | - There is now a reset command to ease the removal of cached token and force re-authentication on next invocation. 64 | 65 | ## [1.0.0] - 2021-07-22 66 | ### Server 67 | #### Added 68 | - `/auth` route for ldap authentication, returning an ExecCredential 69 | - `/token` route for apiserver TokenReview validation 70 | - Loading key pair for jwt signing and validation from files 71 | - Generating an arbitrary key pair for jwt signing and validation if none is given 72 | - TokenReview contains user id and groups from LDAP 73 | 74 | ### Client 75 | #### Added 76 | - Password and username can be given from standard input, environment variables or files. 77 | -------------------------------------------------------------------------------- /client/interactive.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/mattn/go-isatty" 13 | "github.com/rs/zerolog/log" 14 | "github.com/zalando/go-keyring" 15 | "golang.org/x/term" 16 | 17 | "k8s-ldap-auth/types" 18 | ) 19 | 20 | func readData(readLine func(screen io.ReadWriter) (string, error)) (string, error) { 21 | if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { 22 | return "", fmt.Errorf("stdin should be terminal") 23 | } 24 | 25 | oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 26 | if err != nil { 27 | return "", err 28 | } 29 | defer term.Restore(int(os.Stdin.Fd()), oldState) 30 | 31 | screen := struct { 32 | io.Reader 33 | io.Writer 34 | }{os.Stdin, os.Stdout} 35 | 36 | line, err := readLine(screen) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | return line, nil 42 | } 43 | 44 | func username(screen io.ReadWriter) (string, error) { 45 | terminal := term.NewTerminal(screen, "") 46 | 47 | print("username: ") 48 | 49 | line, err := terminal.ReadLine() 50 | if err == io.EOF || line == "" { 51 | return "", fmt.Errorf("username cannot be empty") 52 | } 53 | 54 | return line, err 55 | } 56 | 57 | func password(screen io.ReadWriter) (string, error) { 58 | terminal := term.NewTerminal(screen, "") 59 | 60 | print("password: ") 61 | 62 | line, err := terminal.ReadPassword("") 63 | 64 | if err == io.EOF || line == "" { 65 | return "", fmt.Errorf("password cannot be empty") 66 | } 67 | 68 | return line, err 69 | } 70 | 71 | func performAuth(addr, user, pass string) ([]byte, error) { 72 | var ( 73 | err error 74 | res *http.Response 75 | ) 76 | 77 | interactiveMode := false 78 | 79 | if user == "" { 80 | log.Info().Msg("Username was not provided, asking for input") 81 | user, err = readData(username) 82 | print("\n") 83 | if err != nil { 84 | return nil, err 85 | } 86 | } 87 | log.Info().Str("username", user).Msg("Username exists.") 88 | 89 | if pass == "" { 90 | pass, err = keyring.Get(addr, user) 91 | if err != nil { 92 | log.Error().Err(err).Msg("Error while fetching credentials from store.") 93 | } 94 | } 95 | 96 | if pass == "" { 97 | interactiveMode = true 98 | log.Info().Msg("Password was not provided, asking for input") 99 | pass, err = readData(password) 100 | print("\n") 101 | if err != nil { 102 | return nil, err 103 | } 104 | } 105 | log.Info().Msg("Password exists.") 106 | 107 | cred := types.Credentials{ 108 | Username: user, 109 | Password: pass, 110 | } 111 | data, err := json.Marshal(cred) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | res, err = http.Post(addr, "application/json", bytes.NewBuffer(data)) 117 | if err != nil { 118 | return nil, err 119 | } 120 | defer res.Body.Close() 121 | 122 | if res.StatusCode != http.StatusOK { 123 | if err := keyring.Delete(addr, user); err != nil { 124 | log.Error().Err(err).Msg("Error while removing credentials from store.") 125 | } 126 | return nil, fmt.Errorf(http.StatusText(res.StatusCode)) 127 | } 128 | 129 | var body []byte 130 | body, err = ioutil.ReadAll(res.Body) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | if interactiveMode { 136 | if err = keyring.Set(addr, user, pass); err != nil { 137 | log.Error().Err(err).Msg("Error while registering credentials into store.") 138 | } 139 | } 140 | 141 | return body, nil 142 | } 143 | -------------------------------------------------------------------------------- /ldap/ldap.go: -------------------------------------------------------------------------------- 1 | package ldap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | ldap "github.com/go-ldap/ldap/v3" 8 | "github.com/rs/zerolog/log" 9 | 10 | auth "k8s.io/api/authentication/v1" 11 | ) 12 | 13 | type Ldap struct { 14 | ldapURL string 15 | bindDN string 16 | bindPassword string 17 | searchBase string 18 | searchScope string 19 | searchFilter string 20 | memberofProperty string 21 | usernameProperty string 22 | extraAttributes []string 23 | searchAttributes []string 24 | } 25 | 26 | func sanitize(a []string) []string { 27 | var res []string 28 | 29 | for _, item := range a { 30 | res = append(res, strings.ToLower(item)) 31 | } 32 | 33 | return res 34 | } 35 | 36 | func NewInstance( 37 | ldapURL, 38 | bindDN, 39 | bindPassword, 40 | searchBase, 41 | searchScope, 42 | searchFilter, 43 | memberofProperty, 44 | usernameProperty string, 45 | extraAttributes, 46 | searchAttributes []string, 47 | ) *Ldap { 48 | s := &Ldap{ 49 | ldapURL: ldapURL, 50 | bindDN: bindDN, 51 | bindPassword: bindPassword, 52 | searchBase: searchBase, 53 | searchScope: searchScope, 54 | searchFilter: searchFilter, 55 | memberofProperty: memberofProperty, 56 | usernameProperty: usernameProperty, 57 | extraAttributes: extraAttributes, 58 | searchAttributes: searchAttributes, 59 | } 60 | 61 | return s 62 | } 63 | 64 | func (s *Ldap) Bind() (*ldap.Conn, error) { 65 | l, err := ldap.DialURL(s.ldapURL) 66 | if err != nil { 67 | return nil, err 68 | } 69 | log.Debug().Msg("Successfully dialed ldap.") 70 | 71 | err = l.Bind(s.bindDN, s.bindPassword) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | log.Debug().Msg("Successfully authenticated to ldap.") 77 | 78 | return l, nil 79 | } 80 | 81 | func (s *Ldap) Search(username, password string) (*auth.UserInfo, error) { 82 | l, err := s.Bind() 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | defer l.Close() 88 | 89 | // Execute LDAP Search request 90 | searchRequest := ldap.NewSearchRequest( 91 | s.searchBase, 92 | scopeMap[s.searchScope], 93 | ldap.NeverDerefAliases, // Dereference aliases 94 | 0, // Size limit (0 = no limit) 95 | 0, // Time limit (0 = no limit) 96 | false, // Types only 97 | fmt.Sprintf(s.searchFilter, username), 98 | s.searchAttributes, 99 | nil, // Additional 'Controls' 100 | ) 101 | result, err := l.Search(searchRequest) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | // If LDAP Search produced a result, return UserInfo, otherwise, return nil 107 | if len(result.Entries) == 0 { 108 | return nil, fmt.Errorf("User not found") 109 | } else if len(result.Entries) > 1 { 110 | return nil, fmt.Errorf("Too many entries returned") 111 | } 112 | 113 | // Bind as the user to verify their password 114 | err = l.Bind(result.Entries[0].DN, password) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | var extra map[string]auth.ExtraValue 120 | 121 | for _, item := range s.extraAttributes { 122 | extra[item] = result.Entries[0].GetAttributeValues(item) 123 | } 124 | 125 | user := &auth.UserInfo{ 126 | UID: strings.ToLower(result.Entries[0].DN), 127 | Username: strings.ToLower(result.Entries[0].GetAttributeValue(s.usernameProperty)), 128 | Groups: sanitize(result.Entries[0].GetAttributeValues(s.memberofProperty)), 129 | Extra: extra, 130 | } 131 | 132 | log.Debug().Str("uid", user.UID).Strs("groups", user.Groups).Str("username", user.Username).Msg("Research returned a result.") 133 | 134 | return user, nil 135 | } 136 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # COLORS 2 | TARGET_MAX_CHAR_NUM := 10 3 | GREEN := $(shell tput -Txterm setaf 2) 4 | YELLOW := $(shell tput -Txterm setaf 3) 5 | WHITE := $(shell tput -Txterm setaf 7) 6 | RESET := $(shell tput -Txterm sgr0) 7 | 8 | # The binary to build (just the basename). 9 | PWD := $(shell pwd) 10 | NOW := $(shell date +%s) 11 | APPNAME := k8s-ldap-auth 12 | BIN ?= $(APPNAME) 13 | 14 | ORG ?= registry.aegir.bouchaud.org 15 | PKG := hopopops/$(APPNAME) 16 | PLATFORM ?= "linux/arm/v7,linux/arm64/v8,linux/amd64" 17 | GO ?= go 18 | SED ?= sed 19 | GOFMT ?= gofmt -s 20 | GOFILES := $(shell find . -name "*.go" -type f) 21 | GOVERSION := $(shell $(GO) version | $(SED) -r 's/go version go(.+)\s.+/\1/') 22 | PACKAGES ?= $(shell $(GO) list ./...) 23 | 24 | # This version-strategy uses git tags to set the version string 25 | GIT_TAG := $(shell git describe --tags --always --dirty || echo unsupported) 26 | GIT_COMMIT := $(shell git rev-parse --short HEAD || echo unsupported) 27 | BUILDTIME := $(shell date -u +"%FT%TZ%:z") 28 | TAG ?= $(GIT_TAG) 29 | VERSION ?= $(GIT_TAG) 30 | 31 | .PHONY: fmt fmt-check vet test test-coverage cover install hooks docker tag push help clean dev 32 | default: help 33 | 34 | ## Format go source code 35 | fmt: 36 | $(GOFMT) -w $(GOFILES) 37 | 38 | ## Check if source code is formatted correctly 39 | fmt-check: 40 | @diff=$$($(GOFMT) -d $(GOFILES)); \ 41 | if [ -n "$$diff" ]; then \ 42 | echo "Please run 'make fmt' and commit the result:"; \ 43 | echo "$${diff}"; \ 44 | exit 1; \ 45 | fi; 46 | 47 | ## Check source code for common errors 48 | vet: 49 | $(GO) vet ${PACKAGES} 50 | 51 | ## Execute unit tests 52 | test: 53 | $(GO) test ${PACKAGES} 54 | 55 | ## Execute unit tests & compute coverage 56 | test-coverage: 57 | $(GO) test -coverprofile=coverage.out ${PACKAGES} 58 | 59 | ## Compute coverage 60 | cover: test-coverage 61 | $(GO) tool cover -html=coverage.out 62 | 63 | ## Tidy dependencies 64 | tidy: 65 | $(GO) mod tidy 66 | 67 | ## Install dependencies used for development 68 | install: hooks tidy 69 | $(GO) mod download 70 | 71 | ## Install git hooks for post-checkout & pre-commit 72 | hooks: 73 | @cp -f ./scripts/post-checkout .git/hooks/ 74 | @cp -f ./scripts/pre-commit .git/hooks/ 75 | @chmod +x .git/hooks/post-checkout 76 | @chmod +x .git/hooks/pre-commit 77 | 78 | ## Build the docker images 79 | docker: 80 | @docker buildx build \ 81 | --push \ 82 | --build-arg COMMITHASH="$(GIT_COMMIT)" \ 83 | --build-arg BUILDTIME="$(BUILDTIME)" \ 84 | --build-arg VERSION="$(VERSION)" \ 85 | --build-arg PKG="$(PKG)" \ 86 | --build-arg APPNAME="$(APPNAME)" \ 87 | --platform $(PLATFORM) \ 88 | --tag $(ORG)/$(BIN):$(TAG) \ 89 | --tag $(ORG)/$(BIN):latest \ 90 | . 91 | 92 | ## Clean artifacts 93 | clean: 94 | rm -f $(BIN) $(BIN)-dev $(BIN)-packed 95 | 96 | $(APPNAME): 97 | $(GO) build \ 98 | -trimpath \ 99 | -buildmode=pie \ 100 | -mod=readonly \ 101 | -modcacherw \ 102 | -o $(BIN) \ 103 | -ldflags "\ 104 | -X $(PKG)/version.APPNAME=$(APPNAME) \ 105 | -X $(PKG)/version.VERSION=$(VERSION) \ 106 | -X $(PKG)/version.GOVERSION=$(GOVERSION) \ 107 | -X $(PKG)/version.BUILDTIME=$(BUILDTIME) \ 108 | -X $(PKG)/version.COMMITHASH=$(GIT_COMMIT) \ 109 | -s -w" 110 | 111 | $(APPNAME)-dev: 112 | $(GO) build \ 113 | -o $(BIN)-dev -ldflags "\ 114 | -X $(PKG)/version.APPNAME=$(APPNAME) \ 115 | -X $(PKG)/version.VERSION=$(VERSION) \ 116 | -X $(PKG)/version.GOVERSION=$(GOVERSION) \ 117 | -X $(PKG)/version.BUILDTIME=$(BUILDTIME) \ 118 | -X $(PKG)/version.COMMITHASH=$(GIT_COMMIT)" 119 | 120 | ## Dev build outside of docker, not stripped 121 | dev: $(APPNAME)-dev 122 | 123 | $(APPNAME)-packed: $(APPNAME) 124 | upx --best $(APPNAME) -o $(APPNAME)-packed 125 | 126 | ## Release build outside of docker, stripped and packed 127 | release: $(APPNAME)-packed 128 | 129 | ## Print this help message 130 | help: 131 | @echo '' 132 | @echo 'Usage:' 133 | @echo ' ${YELLOW}make${RESET} ${GREEN}${RESET}' 134 | @echo '' 135 | @echo 'Targets:' 136 | @awk '/^[a-zA-Z\-_0-9]+:/ { \ 137 | helpMessage = match(lastLine, /^## (.*)/); \ 138 | if (helpMessage) { \ 139 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 140 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 141 | printf " ${YELLOW}%-$(TARGET_MAX_CHAR_NUM)s${RESET} ${GREEN}%s${RESET}\n", helpCommand, helpMessage; \ 142 | } \ 143 | } \ 144 | { lastLine = $$0 }' $(MAKEFILE_LIST) 145 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "k8s-ldap-auth/server" 9 | ) 10 | 11 | func getServerCmd() *cli.Command { 12 | return &cli.Command{ 13 | Name: "server", 14 | Aliases: []string{"s", "serve"}, 15 | Usage: "start the authentication server", 16 | HideHelp: false, 17 | Flags: []cli.Flag{ 18 | // server configuration 19 | &cli.StringFlag{ 20 | Name: "host", 21 | Value: "", 22 | EnvVars: []string{"HOST"}, 23 | Usage: "The `HOST` the server will listen on.", 24 | }, 25 | &cli.IntFlag{ 26 | Name: "port", 27 | Value: 3000, 28 | EnvVars: []string{"PORT"}, 29 | Usage: "The `PORT` the server will listen to.", 30 | }, 31 | 32 | // ldap server configuration 33 | &cli.StringFlag{ 34 | Name: "ldap-host", 35 | Value: "ldap://localhost", 36 | EnvVars: []string{"LDAP_ADDR"}, 37 | Usage: "The ldap `HOST` (and scheme) the server will authenticate against.", 38 | }, 39 | 40 | // bind dn configuration 41 | &cli.StringFlag{ 42 | Name: "bind-dn", 43 | EnvVars: []string{"LDAP_BINDDN"}, 44 | Required: true, 45 | Usage: "The service account `DN` to do the ldap search.", 46 | }, 47 | &cli.StringFlag{ 48 | Name: "bind-credentials", 49 | EnvVars: []string{"LDAP_BINDCREDENTIALS"}, 50 | FilePath: "/etc/k8s-ldap-auth/ldap/password", 51 | Usage: "The service account `PASSWORD` to do the ldap search, can be located in '/etc/k8s-ldap-auth/ldap/password'.", 52 | }, 53 | 54 | // user search configuration 55 | &cli.StringFlag{ 56 | Name: "search-base", 57 | EnvVars: []string{"LDAP_USER_SEARCHBASE"}, 58 | Usage: "The `DN` where the ldap search will take place.", 59 | }, 60 | &cli.StringFlag{ 61 | Name: "search-filter", 62 | Value: "(&(objectClass=inetOrgPerson)(uid=%s))", 63 | EnvVars: []string{"LDAP_USER_SEARCHFILTER"}, 64 | Usage: "The `FILTER` to select users.", 65 | }, 66 | &cli.StringFlag{ 67 | Name: "memberof-property", 68 | Value: "ismemberof", 69 | EnvVars: []string{"LDAP_USER_MEMBEROFPROPERTY"}, 70 | Usage: "The `PROPERTY` that will be used to fetch groups. Usually memberof or ismemberof.", 71 | }, 72 | &cli.StringFlag{ 73 | Name: "username-property", 74 | Value: "uid", 75 | EnvVars: []string{"LDAP_USER_USERNAMEPROPERTY"}, 76 | Usage: "The `PROPERTY` that will be used as username in the TokenReview.", 77 | }, 78 | &cli.StringSliceFlag{ 79 | Name: "extra-attributes", 80 | EnvVars: []string{"LDAP_USER_EXTRAATTR"}, 81 | Usage: "Repeatable. User `PROPERTY` to fetch. Those will be stored in extra values in the UserInfo object.", 82 | }, 83 | &cli.StringFlag{ 84 | Name: "search-scope", 85 | Value: "sub", 86 | EnvVars: []string{"LDAP_USER_SEARCHSCOPE"}, 87 | Usage: "The `SCOPE` of the search. Can take to values base object: 'base', single level: 'single' or whole subtree: 'sub'.", 88 | }, 89 | 90 | // jtw signing configuration 91 | &cli.StringFlag{ 92 | Name: "private-key-file", 93 | Usage: "The `PATH` to the private key file", 94 | EnvVars: []string{"PRIVATE_KEY_FILE"}, 95 | }, 96 | &cli.StringFlag{ 97 | Name: "public-key-file", 98 | Usage: "The `PATH` to the public key file", 99 | EnvVars: []string{"PUBLIC_KEY_FILE"}, 100 | }, 101 | &cli.Int64Flag{ 102 | Name: "token-ttl", 103 | Value: 43200, 104 | EnvVars: []string{"TTL"}, 105 | Usage: "The `TTL` for newly generated tokens, in seconds", 106 | }, 107 | }, 108 | Action: func(c *cli.Context) error { 109 | var ( 110 | port = c.Int("port") 111 | host = c.String("host") 112 | 113 | ldapURL = c.String("ldap-host") 114 | bindDN = c.String("bind-dn") 115 | bindPassword = c.String("bind-credentials") 116 | searchBase = c.String("search-base") 117 | searchScope = c.String("search-scope") 118 | searchFilter = c.String("search-filter") 119 | extraAttributes = c.StringSlice("extra-attributes") 120 | memberofProperty = c.String("memberof-property") 121 | usernameProperty = c.String("username-property") 122 | 123 | privateKeyFile = c.String("private-key-file") 124 | publicKeyFile = c.String("public-key-file") 125 | 126 | ttl = c.Int64("token-ttl") 127 | ) 128 | 129 | addr := fmt.Sprintf("%s:%d", host, port) 130 | 131 | s, err := server.NewInstance( 132 | server.WithLdap( 133 | ldapURL, 134 | bindDN, 135 | bindPassword, 136 | searchBase, 137 | searchScope, 138 | searchFilter, 139 | memberofProperty, 140 | usernameProperty, 141 | extraAttributes, 142 | ), 143 | server.WithAccessLogs(), 144 | server.WithKey( 145 | privateKeyFile, 146 | publicKeyFile, 147 | ), 148 | server.WithTTL(ttl), 149 | ) 150 | if err != nil { 151 | return fmt.Errorf("There was an error instanciation the server, %w", err) 152 | } 153 | 154 | if err := s.Start(addr); err != nil { 155 | return fmt.Errorf("There was an error starting the server, %w", err) 156 | } 157 | 158 | return nil 159 | }, 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | vianney@hopopops.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/etherlabsio/healthcheck/v2" 12 | "github.com/gorilla/mux" 13 | "github.com/rs/zerolog/log" 14 | 15 | auth "k8s.io/api/authentication/v1" 16 | machinery "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | client "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" 18 | 19 | "k8s-ldap-auth/ldap" 20 | "k8s-ldap-auth/types" 21 | ) 22 | 23 | const ContentTypeHeader = "Content-Type" 24 | const ContentTypeJSON = "application/json" 25 | 26 | type Instance struct { 27 | l *ldap.Ldap 28 | m []mux.MiddlewareFunc 29 | k *rsa.PrivateKey 30 | ttl int64 31 | } 32 | 33 | func NewInstance(opts ...Option) (*Instance, error) { 34 | s := &Instance{ 35 | m: []mux.MiddlewareFunc{}, 36 | } 37 | 38 | log.Info().Msg("Applying extra options.") 39 | for _, opt := range opts { 40 | err := opt(s) 41 | if err != nil { 42 | return nil, err 43 | } 44 | } 45 | 46 | r := mux.NewRouter() 47 | 48 | log.Info().Msg("Registering route handlers.") 49 | r.HandleFunc("/auth", s.authenticate()).Methods("POST") 50 | r.HandleFunc("/token", s.validate()).Methods("POST") 51 | r.Handle("/health", healthcheck.Handler( 52 | healthcheck.WithTimeout(5*time.Second), 53 | healthcheck.WithChecker( 54 | "ldap", healthcheck.CheckerFunc( 55 | func(_ context.Context) error { 56 | c, err := s.l.Bind() 57 | 58 | if err != nil { 59 | return err 60 | } 61 | 62 | defer c.Close() 63 | 64 | return nil 65 | }, 66 | ), 67 | ), 68 | )) 69 | 70 | log.Info().Msg("Applying middlewares.") 71 | r.Use(s.m...) 72 | 73 | http.Handle("/", r) 74 | 75 | return s, nil 76 | } 77 | 78 | func (s *Instance) Start(addr string) error { 79 | if err := http.ListenAndServe(addr, nil); err != http.ErrServerClosed { 80 | return fmt.Errorf("Server stopped unexpectedly, %w", err) 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func writeExecCredentialError(res http.ResponseWriter, s *ServerError) { 87 | res.WriteHeader(s.s) 88 | 89 | ec := client.ExecCredential{ 90 | Spec: client.ExecCredentialSpec{}, 91 | } 92 | 93 | res.Header().Set(ContentTypeHeader, ContentTypeJSON) 94 | json.NewEncoder(res).Encode(ec) 95 | } 96 | 97 | func (s *Instance) authenticate() http.HandlerFunc { 98 | return func(res http.ResponseWriter, req *http.Request) { 99 | if req.Header.Get(ContentTypeHeader) != ContentTypeJSON { 100 | writeExecCredentialError(res, ErrNotAcceptable) 101 | return 102 | } 103 | 104 | decoder := json.NewDecoder(req.Body) 105 | var credentials types.Credentials 106 | if err := decoder.Decode(&credentials); err != nil { 107 | writeExecCredentialError(res, ErrDecodeFailed) 108 | return 109 | } 110 | defer req.Body.Close() 111 | 112 | if !credentials.IsValid() { 113 | writeExecCredentialError(res, ErrMalformedCredentials) 114 | return 115 | } 116 | 117 | log.Debug().Str("username", credentials.Username).Msg("Received valid authentication request.") 118 | user, err := s.l.Search(credentials.Username, credentials.Password) 119 | if err != nil { 120 | writeExecCredentialError(res, ErrUnauthorized) 121 | return 122 | } 123 | 124 | log.Debug().Str("username", credentials.Username).Msg("Successfully authenticated.") 125 | 126 | token, err := types.NewToken(user, s.ttl) 127 | if err != nil { 128 | writeExecCredentialError(res, ErrServerError) 129 | return 130 | } 131 | 132 | tokenData, err := token.Payload(s.k) 133 | if err != nil { 134 | writeExecCredentialError(res, ErrServerError) 135 | return 136 | } 137 | 138 | tokenExp, err := token.Expiration() 139 | if err != nil { 140 | writeExecCredentialError(res, ErrServerError) 141 | return 142 | } 143 | 144 | log.Debug().Str("username", credentials.Username).Str("token", string(tokenData)).Msg("Sending back token.") 145 | 146 | res.Header().Set(ContentTypeHeader, ContentTypeJSON) 147 | json.NewEncoder(res).Encode(client.ExecCredential{ 148 | Status: &client.ExecCredentialStatus{ 149 | Token: string(tokenData), 150 | ExpirationTimestamp: &machinery.Time{ 151 | Time: tokenExp, 152 | }, 153 | }, 154 | }) 155 | } 156 | } 157 | 158 | func writeError(res http.ResponseWriter, s *ServerError) { 159 | res.WriteHeader(s.s) 160 | res.Write([]byte(s.e.Error())) 161 | } 162 | 163 | func writeTokenReviewError(res http.ResponseWriter, s *ServerError, tr auth.TokenReview) { 164 | res.WriteHeader(s.s) 165 | 166 | tr.Status.Authenticated = false 167 | tr.Status.Error = s.e.Error() 168 | 169 | res.Header().Set(ContentTypeHeader, ContentTypeJSON) 170 | json.NewEncoder(res).Encode(tr) 171 | } 172 | 173 | func (s *Instance) validate() http.HandlerFunc { 174 | return func(res http.ResponseWriter, req *http.Request) { 175 | log.Debug().Msg("Got a request.") 176 | 177 | if req.Header.Get(ContentTypeHeader) != ContentTypeJSON { 178 | writeError(res, ErrNotAcceptable) 179 | return 180 | } 181 | 182 | log.Debug().Msg("Request is in JSON.") 183 | 184 | decoder := json.NewDecoder(req.Body) 185 | var tr auth.TokenReview 186 | if err := decoder.Decode(&tr); err != nil { 187 | writeError(res, ErrDecodeFailed) 188 | return 189 | } 190 | defer req.Body.Close() 191 | 192 | log.Debug().Str("token", tr.Spec.Token).Msg("Request is a TokenReview.") 193 | 194 | token, err := types.Parse([]byte(tr.Spec.Token), s.k) 195 | if err != nil { 196 | log.Debug().Str("err", err.Error()).Msg("Failed to parse token") 197 | 198 | writeTokenReviewError(res, ErrMalformedToken, tr) 199 | return 200 | } 201 | 202 | log.Debug().Msg("TokenReview was parsed.") 203 | 204 | if token.IsValid() == false { 205 | log.Debug().Msg("TokenReview is not valid.") 206 | tr.Status.Authenticated = false 207 | } else { 208 | user, err := token.GetUser() 209 | if err != nil { 210 | log.Debug().Str("error", err.Error()).Msg("Could not extract user.") 211 | 212 | writeTokenReviewError(res, ErrServerError, tr) 213 | return 214 | } 215 | 216 | log.Debug().Msg("Got user from token.") 217 | 218 | tr.Status.Authenticated = true 219 | tr.Status.User = *user 220 | } 221 | 222 | res.Header().Set(ContentTypeHeader, ContentTypeJSON) 223 | json.NewEncoder(res).Encode(tr) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v*.*.*' 8 | 9 | jobs: 10 | release: 11 | name: Create release and upload binaries 12 | runs-on: ubuntu-latest 13 | 14 | if: ${{ github.actor != 'dependabot[bot]' }} 15 | 16 | strategy: 17 | matrix: 18 | target: 19 | - "linux:arm64:" 20 | - "linux:arm:" 21 | - "linux:amd64:" 22 | - "darwin:arm64:" 23 | - "darwin:amd64:" 24 | - "windows:amd64:.exe" 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v6 29 | 30 | - name: Set up Go 31 | uses: actions/setup-go@v6 32 | with: 33 | go-version: "1.21" 34 | 35 | - name: Prepare 36 | id: prep 37 | run: |- 38 | PRE_RELEASE=false 39 | if [[ $GITHUB_REF == refs/tags/* ]]; then 40 | VERSION=${GITHUB_REF#refs/tags/} 41 | if [[ $VERSION =~ ^*-pre$ ]]; then 42 | PRE_RELEASE=true 43 | fi 44 | fi 45 | echo ::set-output name=version::${VERSION} 46 | echo ::set-output name=buildtime::$(date -u +'%FT%TZ%:z') 47 | echo ::set-output name=pre::${PRE_RELEASE} 48 | echo ::set-output name=go-version::$(go version | sed -r 's/go version go(.+)\s.+/\1/') 49 | 50 | - name: Cache 51 | id: cache 52 | uses: actions/cache@v2 53 | with: 54 | path: |- 55 | ~/.cache/go-build 56 | ~/go/pkg/mod 57 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 58 | restore-keys: |- 59 | ${{ runner.os }}-go- 60 | 61 | - name: Install Dependencies 62 | if: steps.cache.outputs.cache-hit != 'true' 63 | run: |- 64 | go mod download 65 | 66 | - name: Build 67 | run: |- 68 | export GOOS="$(echo ${{ matrix.target }} | cut -d':' -f1)" 69 | export GOARCH="$(echo ${{ matrix.target }} | cut -d':' -f2)" 70 | export EXT="$(echo ${{ matrix.target }} | cut -d':' -f3)" 71 | 72 | if [ $GOARCH = arm ]; then 73 | go build \ 74 | -trimpath \ 75 | -mod=readonly \ 76 | -modcacherw \ 77 | -o ${{ secrets.APP_NAME }}.${GOOS}.${GOARCH} \ 78 | -ldflags "\ 79 | -X ${{ github.repository }}/version.APPNAME=${{ secrets.APP_NAME }} \ 80 | -X ${{ github.repository }}/version.VERSION=${{ steps.prep.outputs.version }} \ 81 | -X ${{ github.repository }}/version.GOVERSION=${{ steps.prep.outputs.go-version }} \ 82 | -X ${{ github.repository }}/version.BUILDTIME=${{ steps.prep.outputs.buildtime }} \ 83 | -X ${{ github.repository }}/version.COMMITHASH=${{ github.sha }} \ 84 | -s -w" 85 | else 86 | go build \ 87 | -trimpath \ 88 | -buildmode=pie \ 89 | -mod=readonly \ 90 | -modcacherw \ 91 | -o ${{ secrets.APP_NAME }}.${GOOS}.${GOARCH} \ 92 | -ldflags "\ 93 | -X ${{ github.repository }}/version.APPNAME=${{ secrets.APP_NAME }} \ 94 | -X ${{ github.repository }}/version.VERSION=${{ steps.prep.outputs.version }} \ 95 | -X ${{ github.repository }}/version.GOVERSION=${{ steps.prep.outputs.go-version }} \ 96 | -X ${{ github.repository }}/version.BUILDTIME=${{ steps.prep.outputs.buildtime }} \ 97 | -X ${{ github.repository }}/version.COMMITHASH=${{ github.sha }} \ 98 | -s -w" 99 | fi 100 | 101 | if [ ! -z "${EXT}" ]; then 102 | mv ${{ secrets.APP_NAME }}.${GOOS}.${GOARCH} ${{ secrets.APP_NAME }}${EXT} 103 | sha256sum ${{ secrets.APP_NAME }}${EXT} > ${{ secrets.APP_NAME }}${EXT}.sha256sum.txt 104 | else 105 | sha256sum ${{ secrets.APP_NAME }}.${GOOS}.${GOARCH} > ${{ secrets.APP_NAME }}.${GOOS}.${GOARCH}.sha256sum.txt 106 | fi 107 | 108 | - name: Extract release notes 109 | id: extract-release-notes 110 | uses: ffurrer2/extract-release-notes@v3 111 | 112 | - name: Upload binary and sum file 113 | uses: svenstaro/upload-release-action@v2 114 | with: 115 | repo_token: ${{ secrets.GITHUB_TOKEN }} 116 | file: ${{ secrets.APP_NAME }}.* 117 | file_glob: true 118 | tag: ${{ github.ref }} 119 | overwrite: true 120 | release_name: ${{ steps.prep.outputs.version }} 121 | body: ${{ steps.extract-release-notes.outputs.release_notes }} 122 | prerelease: steps.prep.outputs.pre != 'false' 123 | 124 | docker: 125 | name: Release Docker Images 126 | runs-on: ubuntu-latest 127 | steps: 128 | - name: Checkout 129 | uses: actions/checkout@v6 130 | 131 | - name: Prepare 132 | id: prep 133 | run: |- 134 | if [[ $GITHUB_REF == refs/tags/* ]]; then 135 | VERSION=${GITHUB_REF#refs/tags/} 136 | if [[ $VERSION =~ ^v([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then 137 | MAJOR="${BASH_REMATCH[1]}" 138 | MINOR="${BASH_REMATCH[2]}" 139 | PATCH="${BASH_REMATCH[3]}" 140 | 141 | TAGS="${{ secrets.DOCKER_IMAGE }}:latest" 142 | TAGS="${TAGS},${{ secrets.DOCKER_IMAGE }}:v${MAJOR}" 143 | TAGS="${TAGS},${{ secrets.DOCKER_IMAGE }}:v${MAJOR}.${MINOR}" 144 | TAGS="${TAGS},${{ secrets.DOCKER_IMAGE }}:v${MAJOR}.${MINOR}.${PATCH}" 145 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:latest" 146 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:v${MAJOR}" 147 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:v${MAJOR}.${MINOR}" 148 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:v${MAJOR}.${MINOR}.${PATCH}" 149 | else 150 | TAGS="${{ secrets.DOCKER_IMAGE }}:${VERSION}" 151 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:${VERSION}" 152 | fi 153 | elif [[ $GITHUB_REF == refs/heads/* ]]; then 154 | VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g') 155 | if [ "${{ github.event.repository.default_branch }}" = "$VERSION" ]; then 156 | VERSION=edge 157 | fi 158 | TAGS="${{ secrets.DOCKER_IMAGE }}:${VERSION}" 159 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:${VERSION}" 160 | elif [[ $GITHUB_REF == refs/pull/* ]]; then 161 | VERSION=pr-${{ github.event.number }} 162 | TAGS="${{ secrets.DOCKER_IMAGE }}:${VERSION}" 163 | TAGS="${TAGS},${{ secrets.QUAY_IMAGE }}:${VERSION}" 164 | fi 165 | echo ::set-output name=tags::${TAGS} 166 | echo ::set-output name=version::${VERSION} 167 | echo ::set-output name=buildtime::$(date -u +'%FT%TZ%:z') 168 | echo ::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ') 169 | 170 | - name: Set up QEMU 171 | uses: docker/setup-qemu-action@v1 172 | 173 | - name: Set up Docker Buildx 174 | uses: docker/setup-buildx-action@v3 175 | 176 | - name: Login to docker container registry 177 | uses: docker/login-action@v1 178 | with: 179 | username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} 180 | password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} 181 | 182 | - name: Login to quay container registry 183 | uses: docker/login-action@v1 184 | with: 185 | registry: quay.io 186 | username: ${{ secrets.QUAY_REGISTRY_USERNAME }} 187 | password: ${{ secrets.QUAY_REGISTRY_TOKEN }} 188 | 189 | - name: Build and push 190 | id: docker_build 191 | uses: docker/build-push-action@v2 192 | with: 193 | context: . 194 | file: ./Dockerfile 195 | platforms: linux/amd64,linux/arm/v7,linux/arm64 196 | push: true 197 | tags: ${{ steps.prep.outputs.tags }} 198 | build-args: |- 199 | APPNAME=${{ secrets.APP_NAME }} 200 | PKG=${{ github.repository }} 201 | VERSION=${{ steps.prep.outputs.version }} 202 | COMMITHASH=${{ github.sha }} 203 | BUILDTIME=${{ steps.prep.outputs.buildtime }} 204 | labels: |- 205 | org.opencontainers.image.title=${{ github.event.repository.name }} 206 | org.opencontainers.image.description=${{ github.event.repository.description }} 207 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 208 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 209 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 210 | org.opencontainers.image.revision=${{ github.sha }} 211 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 212 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # k8s-ldap-auth 2 | 3 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/hopopops/k8s-ldap-auth?style=for-the-badge)](https://github.com/hopopops/k8s-ldap-auth/releases/latest) 4 | [![License](https://img.shields.io/github/license/hopopops/k8s-ldap-auth?style=for-the-badge)](https://opensource.org/licenses/MPL-2.0) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/hopopops/k8s-ldap-auth?style=for-the-badge)](https://goreportcard.com/report/github.com/hopopops/k8s-ldap-auth) 6 | [![Artifact Hub](https://img.shields.io/endpoint?style=for-the-badge&url=https://artifacthub.io/badge/repository/hopopops)](https://artifacthub.io/packages/search?repo=hopopops) 7 | 8 | A webhook token authentication plugin implementation backed by LDAP. 9 | 10 | - [What](#what) 11 | - [Usage](#usage) 12 | * [Server](#server) 13 | + [New cluster](#new-cluster) 14 | + [Existing cluster](#existing-cluster) 15 | * [Client](#client) 16 | * [RBAC](#rbac) 17 | + [Example](#example) 18 | - [Build](#build) 19 | - [Distribution](#distribution) 20 | * [Docker](#docker) 21 | * [Binary](#binary) 22 | * [Linux](#linux) 23 | + [Archlinux](#archlinux) 24 | * [Darwin](#darwin) 25 | + [With `brew`](#with--brew-) 26 | * [Kubernetes](#kubernetes) 27 | + [Helm Chart](#helm-chart) 28 | - [Inspiration](#inspiration) 29 | 30 | ## What 31 | 32 | k8s-ldap-auth is released as a binary containing both client and server. 33 | 34 | The server part provides two routes: 35 | - `/auth` for the actual authentication from the CLI tool 36 | - `/token` for the token validation from the kube-apiserver. 37 | 38 | The user created from the TokenReview will contain both uid and groups from the LDAP user so you can use both for role binding. 39 | 40 | The same k8s-ldap-auth server can be used to authenticate with multiple kubernetes cluster since the ExecCredential it provides contains a signed token that will eventually be used by a kube-apiserver in a TokenReview that will be sent back. 41 | 42 | I actually use this setup on quite a few clusters with a growing userbase. 43 | 44 | Access rights to clusters and resources will not be implemented in this authentication hook, kubernetes RBAC will do that for you. 45 | 46 | `KUBERNETES_EXEC_INFO` is currently disregarded but might be used in future versions. 47 | 48 | ## Usage 49 | 50 | You can see the commands and their options with: 51 | ``` 52 | k8s-ldap-auth --help 53 | # or 54 | k8s-ldap-auth [command] --help 55 | ``` 56 | 57 | Pretty much all options can be set using environment variables and a few also read their values from files. 58 | 59 | ### Server 60 | 61 | Create the password file for the bind-dn: 62 | ``` 63 | echo -n "bind_P@ssw0rd" > /etc/k8s-ldap-auth/ldap/password 64 | ``` 65 | 66 | The server can then be started with: 67 | ``` 68 | k8s-ldap-auth serve \ 69 | --ldap-host="ldaps://ldap.company.local" \ 70 | --bind-dn="uid=k8s-ldap-auth,ou=services,ou=company,ou=local" \ 71 | --search-base="ou=people,ou=company,ou=local" 72 | ``` 73 | 74 | Note that if the server do not know of any key pair it will create one at launch but will not persist it. 75 | If you want your jwt tokens to be valid across server instances, after restarts or behind a load-balancer, you should provide a key pair. 76 | 77 | Key pair can be created with openssl: 78 | ``` 79 | openssl genrsa -out key.pem 4096 80 | openssl rsa -in key.pem -outform PEM -pubout -out public.pem 81 | ``` 82 | 83 | Then, the server can be started with: 84 | ```sh 85 | k8s-ldap-auth serve \ 86 | --ldap-host="ldaps://ldap.company.local" \ 87 | --bind-dn="uid=k8s-ldap-auth,ou=services,ou=company,ou=local" \ 88 | --search-base="ou=people,ou=company,ou=local" \ 89 | --private-key-file="path/to/key.pem" 90 | --public-key-file="path/to/public.pem" 91 | ``` 92 | 93 | Now for the cluster configuration. 94 | 95 | In the following example, I use the api version `client.authentication.k8s.io/v1beta1`. Feel free to put another better suited for your need. 96 | 97 | The following authentication token webhook config file will have to exist on every control-plane. In the following configuration it's located at `/etc/kubernetes/webhook-auth-config.yml`: 98 | ```yml 99 | --- 100 | apiVersion: v1 101 | kind: Config 102 | 103 | clusters: 104 | - name: authentication-server 105 | cluster: 106 | server: https:///token 107 | 108 | users: 109 | - name: kube-apiserver 110 | 111 | contexts: 112 | - context: 113 | cluster: authentication-server 114 | user: kube-apiserver 115 | name: kube-apiserver@authentication-server 116 | 117 | current-context: kube-apiserver@authentication-server 118 | ``` 119 | 120 | #### New cluster 121 | 122 | If you're creating a new cluster with kubeadm, you can add the following to your init configuration file: 123 | ```yml 124 | --- 125 | apiVersion: kubeadm.k8s.io/v1beta2 126 | kind: ClusterConfiguration 127 | apiServer: 128 | extraArgs: 129 | authentication-token-webhook-config-file: "/etc/ldap-auth-webhook/config.yml" 130 | authentication-token-webhook-version: client.authentication.k8s.io/v1beta1 131 | extraVolumes: 132 | - name: "webhook-config" 133 | hostPath: "/etc/kubernetes/webhook-auth-config.yml" 134 | mountPath: "/etc/ldap-auth-webhook/config.yml" 135 | readOnly: true 136 | pathType: File 137 | ``` 138 | 139 | #### Existing cluster 140 | 141 | If the cluster was created with kubeadm, edit the kubeadm configuration stored in the namespace `kube-system` to add the configuration from above: `kubectl --namespace kube-system edit configmaps kubeadm-config` 142 | Editing this configuration does not actually update your api-server. It will however be used if you need to add a new control-plane with `kubeadm join`. 143 | 144 | On every control plane, edit the manifest found at `/etc/kubernetes/manifests/kube-apiserver.yaml`: 145 | ```yml 146 | spec: 147 | containers: 148 | - name: kube-apiserver 149 | command: 150 | - kube-apiserver 151 | # ... 152 | - --authentication-token-webhook-config-file=/etc/ldap-auth-webhook/config.yml 153 | - --authentication-token-webhook-version=v1beta1 154 | 155 | # ... 156 | 157 | volumeMounts: 158 | - mountPath: /etc/ldap-auth-webhook/config.yml 159 | name: webhook-config 160 | readOnly: true 161 | 162 | # ... 163 | 164 | volumes: 165 | - hostPath: 166 | path: /etc/kubernetes/webhook-auth-config.yml 167 | type: File 168 | name: webhook-config 169 | ``` 170 | 171 | ### Client 172 | 173 | Even though it's not specified anywhere, the `--password` option and the equivalent `$PASSWORD` environment variable as well as the configfile containing a password were added for convenience’s sake, e.g. when running in an automated fashion, etc. If not provided, it will be asked at runtime and, if available, saved into the client OS credential manager. The same can be said for the `--user` options and `$USER` environment variables. 174 | 175 | Authentication can be achieved with the following command you can execute to test your installation: 176 | ``` 177 | k8s-ldap-auth auth --endpoint="https:///auth" 178 | ``` 179 | 180 | You can now configure `kubectl` to use `k8s-ldap-auth` to authenticate to clusters by editing your kube config file and adding the following user: 181 | ```yml 182 | users: 183 | - name: my-user 184 | user: 185 | exec: 186 | # In the following, we assume a binary called `k8s-ldap-auth` is 187 | # available in the path. You can instead put the full path to the binary. 188 | # Windows paths do work with kubectl so the following would also work: 189 | # `C:\users\foo\Documents\k8s-ldap-auth.exe`. 190 | command: k8s-ldap-auth 191 | 192 | # This field is used by kubectl to fill a template TokenReview in 193 | # `$KUBERNETES_EXEC_INFO` environment variable. Not currently used, it 194 | # might be in the future. 195 | apiVersion: client.authentication.k8s.io/v1beta1 196 | 197 | env: 198 | # This environment variable is used within `k8s-ldap-auth` to create 199 | # an ExecCredential. Future version of this authenticator might not 200 | # need it but you'll have to provide it for now. 201 | - name: AUTH_API_VERSION 202 | value: client.authentication.k8s.io/v1beta1 203 | 204 | # You can fill a USER environment variable to your username if you 205 | # want to overwrite the USER from your system or to an empty one if you 206 | # want the authenticator to ask for one at runtime. 207 | - name: USER 208 | value: "" 209 | 210 | args: 211 | - authenticate 212 | 213 | # This is the endpoint to authenticate against. Basically, the server 214 | # started with `k8s-ldap-auth server` plus the `/auth` route, used for 215 | # authentication. 216 | - --endpoint=https://k8s-ldap-auth/auth 217 | 218 | installHint: | 219 | k8s-ldap-auth is required to authenticate to the current context. 220 | It can be installed from https://github.com/hopopops/k8s-ldap-auth. 221 | 222 | # This parameter, when true, tells `kubectl` to fill the TokenReview in 223 | # the `$KUBERNETES_EXEC_INFO` environment variable with extra config 224 | # from the definition of the specific cluster currently targeted. 225 | # This is not used today but might be in the future to allow for custom 226 | # rules on a per-cluster basis. 227 | provideClusterInfo: false 228 | ``` 229 | 230 | This user can be used by setting the `--user` attribute for `kubectl`: 231 | ``` 232 | kubectl --user my-user get nodes 233 | ``` 234 | 235 | You can also create contexts with it: 236 | ```yaml 237 | contexts: 238 | - name: context1 239 | context: 240 | cluster: cluster1 241 | user: my-user 242 | - name: context2 243 | context: 244 | cluster: cluster2 245 | user: my-user 246 | 247 | current-context: context1 248 | ``` 249 | 250 | And then: 251 | ``` 252 | kubectl --context context2 get nodes 253 | kubectl get nodes 254 | ``` 255 | 256 | ### RBAC 257 | Before you can actually get some result, you will have to upload some rolebindings to the cluster. As stated before, `k8s-ldap-auth` provides the apiserver with an ExecCredential containing both LDAP username and groups so both can be used in ClusterRoleBindings and RoleBindings. 258 | 259 | Beware: group DNs, username and user id are all set to lowercase in the TokenReview. 260 | 261 | #### Example 262 | 263 | Given the following ldap users: 264 | 265 | ``` 266 | # User Alice 267 | dn: uid=alice,ou=people,ou=company,ou=local 268 | ismemberof: cn=somegroup,ou=groups,ou=company,ou=local 269 | 270 | # User Bob 271 | dn: uid=bob,ou=people,ou=company,ou=local 272 | ismemberof: cn=somegroup,ou=groups,ou=company,ou=local 273 | 274 | # User Carol 275 | dn: uid=carol,ou=people,ou=company,ou=local 276 | ``` 277 | 278 | If I want to bind `cluster-admin` ClusterRole to the user `carol`, I can create a ClusterRoleBinding as following: 279 | ```yaml 280 | apiVersion: rbac.authorization.k8s.io/v1 281 | kind: ClusterRoleBinding 282 | metadata: 283 | name: custom-cluster-admininistrators 284 | roleRef: 285 | apiGroup: rbac.authorization.k8s.io 286 | kind: ClusterRole 287 | name: cluster-admin 288 | subjects: 289 | - apiGroup: rbac.authorization.k8s.io 290 | kind: User 291 | name: carol 292 | ``` 293 | 294 | Let's say I want to bind the `view` ClusterRole so that all user in the group `somegroup` will have view access to a given namespace, I can create a RoleBinding such as: 295 | ```yaml 296 | apiVersion: rbac.authorization.k8s.io/v1 297 | kind: RoleBinding 298 | metadata: 299 | name: namespace-users 300 | namespace: somenamespace 301 | roleRef: 302 | apiGroup: rbac.authorization.k8s.io 303 | kind: ClusterRole 304 | name: view 305 | subjects: 306 | - apiGroup: rbac.authorization.k8s.io 307 | kind: Group 308 | name: cn=somegroup,ou=groups,ou=company,ou=local 309 | ``` 310 | 311 | Note: Kubernetes comes with some basic [predefined roles](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) for you to use. 312 | 313 | ## Build 314 | 315 | A stripped binary can be built with: 316 | ``` 317 | make k8s-ldap-auth 318 | ``` 319 | 320 | A stripped and compressed binary can be build with: 321 | ``` 322 | make release 323 | ``` 324 | 325 | Docker release multi-arch image can be built and pushed with: 326 | ``` 327 | PLATFORM="linux/arm/v7,linux/amd64" make docker 328 | ``` 329 | 330 | `PLATFORM` defaults to `linux/arm/v7,linux/arm64/v8,linux/amd64` 331 | 332 | ## Distribution 333 | ### Docker 334 | Docker images of this project are available for arm/v7, arm64/v8 and amd64 at [hopopops/k8s-ldap-auth](https://hub.docker.com/r/hopopops/k8s-ldap-auth) on docker hub and on quay.io at [hopopops/k8s-ldap-auth](https://quay.io/hopopops/k8s-ldap-auth). 335 | 336 | ### Binary 337 | Binaries for the following OS and architectures are available on the release page: 338 | - linux/arm64 339 | - linux/arm 340 | - linux/amd64 341 | - darwin/arm64 342 | - darwin/amd64 343 | - windows/amd64 344 | 345 | ### Linux 346 | #### Archlinux 347 | [![AUR version](https://img.shields.io/aur/version/k8s-ldap-auth?label=k8s-ldap-auth&style=for-the-badge)](https://aur.archlinux.org/packages/k8s-ldap-auth/) 348 | 349 | [![AUR version](https://img.shields.io/aur/version/k8s-ldap-auth-bin?label=k8s-ldap-auth-bin&style=for-the-badge)](https://aur.archlinux.org/packages/k8s-ldap-auth-bin/) 350 | 351 | [![AUR last modified](https://img.shields.io/aur/last-modified/k8s-ldap-auth-git?label=k8s-ldap-auth-git&style=for-the-badge)](https://aur.archlinux.org/packages/k8s-ldap-auth-git/) 352 | 353 | ### Darwin 354 | #### With `brew` 355 | `k8s-ldap-auth.rb` is not in the official repository, you can install it from [my repository](https://github.com/hopopops/homebrew-tap) with the following commands: 356 | 357 | `brew install hopopops/tap/k8s-ldap-auth` 358 | 359 | Or `brew tap hopopops/tap` and then `brew install k8s-ldap-auth`. 360 | 361 | ### Kubernetes 362 | #### Helm Chart 363 | A Chart is hosted at [hopopops/chartrepo](https://hopopops.github.io/chartrepo/). Please see [its readme](https://github.com/hopopops/chartrepo/blob/main/charts/k8s-ldap-auth/README.md) for more information on how to install it. 364 | 365 | ## Inspiration 366 | I originally started this project after reading Daniel Weibel's article "Implementing LDAP authentication for Kubernetes" (https://learnk8s.io/kubernetes-custom-authentication or https://itnext.io/implementing-ldap-authentication-for-kubernetes-732178ec2155). 367 | 368 | ## What's next 369 | - Group search for ldap not supporting `memberof` attribute ; 370 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. "Contributor" 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. "Contributor Version" 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. "Covered Software" 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the terms of 34 | a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. "Larger Work" 41 | 42 | means a work that combines Covered Software with other material, in a 43 | separate file or files, that is not Covered Software. 44 | 45 | 1.8. "License" 46 | 47 | means this document. 48 | 49 | 1.9. "Licensable" 50 | 51 | means having the right to grant, to the maximum extent possible, whether 52 | at the time of the initial grant or subsequently, any and all of the 53 | rights conveyed by this License. 54 | 55 | 1.10. "Modifications" 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, 60 | deletion from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. "Patent Claims" of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, 67 | process, and apparatus claims, in any patent Licensable by such 68 | Contributor that would be infringed, but for the grant of the License, 69 | by the making, using, selling, offering for sale, having made, import, 70 | or transfer of either its Contributions or its Contributor Version. 71 | 72 | 1.12. "Secondary License" 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. "Source Code Form" 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. "You" (or "Your") 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, "You" includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, "control" means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or 104 | as part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its 108 | Contributions or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution 113 | become effective for each Contribution on the date the Contributor first 114 | distributes such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under 119 | this License. No additional rights or licenses will be implied from the 120 | distribution or licensing of Covered Software under this License. 121 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 122 | Contributor: 123 | 124 | a. for any code that a Contributor has removed from Covered Software; or 125 | 126 | b. for infringements caused by: (i) Your and any other third party's 127 | modifications of Covered Software, or (ii) the combination of its 128 | Contributions with other software (except as part of its Contributor 129 | Version); or 130 | 131 | c. under Patent Claims infringed by Covered Software in the absence of 132 | its Contributions. 133 | 134 | This License does not grant any rights in the trademarks, service marks, 135 | or logos of any Contributor (except as may be necessary to comply with 136 | the notice requirements in Section 3.4). 137 | 138 | 2.4. Subsequent Licenses 139 | 140 | No Contributor makes additional grants as a result of Your choice to 141 | distribute the Covered Software under a subsequent version of this 142 | License (see Section 10.2) or under the terms of a Secondary License (if 143 | permitted under the terms of Section 3.3). 144 | 145 | 2.5. Representation 146 | 147 | Each Contributor represents that the Contributor believes its 148 | Contributions are its original creation(s) or it has sufficient rights to 149 | grant the rights to its Contributions conveyed by this License. 150 | 151 | 2.6. Fair Use 152 | 153 | This License is not intended to limit any rights You have under 154 | applicable copyright doctrines of fair use, fair dealing, or other 155 | equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under 169 | the terms of this License. You must inform recipients that the Source 170 | Code Form of the Covered Software is governed by the terms of this 171 | License, and how they can obtain a copy of this License. You may not 172 | attempt to alter or restrict the recipients' rights in the Source Code 173 | Form. 174 | 175 | 3.2. Distribution of Executable Form 176 | 177 | If You distribute Covered Software in Executable Form then: 178 | 179 | a. such Covered Software must also be made available in Source Code Form, 180 | as described in Section 3.1, and You must inform recipients of the 181 | Executable Form how they can obtain a copy of such Source Code Form by 182 | reasonable means in a timely manner, at a charge no more than the cost 183 | of distribution to the recipient; and 184 | 185 | b. You may distribute such Executable Form under the terms of this 186 | License, or sublicense it under different terms, provided that the 187 | license for the Executable Form does not attempt to limit or alter the 188 | recipients' rights in the Source Code Form under this License. 189 | 190 | 3.3. Distribution of a Larger Work 191 | 192 | You may create and distribute a Larger Work under terms of Your choice, 193 | provided that You also comply with the requirements of this License for 194 | the Covered Software. If the Larger Work is a combination of Covered 195 | Software with a work governed by one or more Secondary Licenses, and the 196 | Covered Software is not Incompatible With Secondary Licenses, this 197 | License permits You to additionally distribute such Covered Software 198 | under the terms of such Secondary License(s), so that the recipient of 199 | the Larger Work may, at their option, further distribute the Covered 200 | Software under the terms of either this License or such Secondary 201 | License(s). 202 | 203 | 3.4. Notices 204 | 205 | You may not remove or alter the substance of any license notices 206 | (including copyright notices, patent notices, disclaimers of warranty, or 207 | limitations of liability) contained within the Source Code Form of the 208 | Covered Software, except that You may alter any license notices to the 209 | extent required to remedy known factual inaccuracies. 210 | 211 | 3.5. Application of Additional Terms 212 | 213 | You may choose to offer, and to charge a fee for, warranty, support, 214 | indemnity or liability obligations to one or more recipients of Covered 215 | Software. However, You may do so only on Your own behalf, and not on 216 | behalf of any Contributor. You must make it absolutely clear that any 217 | such warranty, support, indemnity, or liability obligation is offered by 218 | You alone, and You hereby agree to indemnify every Contributor for any 219 | liability incurred by such Contributor as a result of warranty, support, 220 | indemnity or liability terms You offer. You may include additional 221 | disclaimers of warranty and limitations of liability specific to any 222 | jurisdiction. 223 | 224 | 4. Inability to Comply Due to Statute or Regulation 225 | 226 | If it is impossible for You to comply with any of the terms of this License 227 | with respect to some or all of the Covered Software due to statute, 228 | judicial order, or regulation then You must: (a) comply with the terms of 229 | this License to the maximum extent possible; and (b) describe the 230 | limitations and the code they affect. Such description must be placed in a 231 | text file included with all distributions of the Covered Software under 232 | this License. Except to the extent prohibited by statute or regulation, 233 | such description must be sufficiently detailed for a recipient of ordinary 234 | skill to be able to understand it. 235 | 236 | 5. Termination 237 | 238 | 5.1. The rights granted under this License will terminate automatically if You 239 | fail to comply with any of its terms. However, if You become compliant, 240 | then the rights granted under this License from a particular Contributor 241 | are reinstated (a) provisionally, unless and until such Contributor 242 | explicitly and finally terminates Your grants, and (b) on an ongoing 243 | basis, if such Contributor fails to notify You of the non-compliance by 244 | some reasonable means prior to 60 days after You have come back into 245 | compliance. Moreover, Your grants from a particular Contributor are 246 | reinstated on an ongoing basis if such Contributor notifies You of the 247 | non-compliance by some reasonable means, this is the first time You have 248 | received notice of non-compliance with this License from such 249 | Contributor, and You become compliant prior to 30 days after Your receipt 250 | of the notice. 251 | 252 | 5.2. If You initiate litigation against any entity by asserting a patent 253 | infringement claim (excluding declaratory judgment actions, 254 | counter-claims, and cross-claims) alleging that a Contributor Version 255 | directly or indirectly infringes any patent, then the rights granted to 256 | You by any and all Contributors for the Covered Software under Section 257 | 2.1 of this License shall terminate. 258 | 259 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 260 | license agreements (excluding distributors and resellers) which have been 261 | validly granted by You or Your distributors under this License prior to 262 | termination shall survive termination. 263 | 264 | 6. Disclaimer of Warranty 265 | 266 | Covered Software is provided under this License on an "as is" basis, 267 | without warranty of any kind, either expressed, implied, or statutory, 268 | including, without limitation, warranties that the Covered Software is free 269 | of defects, merchantable, fit for a particular purpose or non-infringing. 270 | The entire risk as to the quality and performance of the Covered Software 271 | is with You. Should any Covered Software prove defective in any respect, 272 | You (not any Contributor) assume the cost of any necessary servicing, 273 | repair, or correction. This disclaimer of warranty constitutes an essential 274 | part of this License. No use of any Covered Software is authorized under 275 | this License except under this disclaimer. 276 | 277 | 7. Limitation of Liability 278 | 279 | Under no circumstances and under no legal theory, whether tort (including 280 | negligence), contract, or otherwise, shall any Contributor, or anyone who 281 | distributes Covered Software as permitted above, be liable to You for any 282 | direct, indirect, special, incidental, or consequential damages of any 283 | character including, without limitation, damages for lost profits, loss of 284 | goodwill, work stoppage, computer failure or malfunction, or any and all 285 | other commercial damages or losses, even if such party shall have been 286 | informed of the possibility of such damages. This limitation of liability 287 | shall not apply to liability for death or personal injury resulting from 288 | such party's negligence to the extent applicable law prohibits such 289 | limitation. Some jurisdictions do not allow the exclusion or limitation of 290 | incidental or consequential damages, so this exclusion and limitation may 291 | not apply to You. 292 | 293 | 8. Litigation 294 | 295 | Any litigation relating to this License may be brought only in the courts 296 | of a jurisdiction where the defendant maintains its principal place of 297 | business and such litigation shall be governed by laws of that 298 | jurisdiction, without reference to its conflict-of-law provisions. Nothing 299 | in this Section shall prevent a party's ability to bring cross-claims or 300 | counter-claims. 301 | 302 | 9. Miscellaneous 303 | 304 | This License represents the complete agreement concerning the subject 305 | matter hereof. If any provision of this License is held to be 306 | unenforceable, such provision shall be reformed only to the extent 307 | necessary to make it enforceable. Any law or regulation which provides that 308 | the language of a contract shall be construed against the drafter shall not 309 | be used to construe this License against a Contributor. 310 | 311 | 312 | 10. Versions of the License 313 | 314 | 10.1. New Versions 315 | 316 | Mozilla Foundation is the license steward. Except as provided in Section 317 | 10.3, no one other than the license steward has the right to modify or 318 | publish new versions of this License. Each version will be given a 319 | distinguishing version number. 320 | 321 | 10.2. Effect of New Versions 322 | 323 | You may distribute the Covered Software under the terms of the version 324 | of the License under which You originally received the Covered Software, 325 | or under the terms of any subsequent version published by the license 326 | steward. 327 | 328 | 10.3. Modified Versions 329 | 330 | If you create software not governed by this License, and you want to 331 | create a new license for such software, you may create and use a 332 | modified version of this License if you rename the license and remove 333 | any references to the name of the license steward (except to note that 334 | such modified license differs from this License). 335 | 336 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 337 | Licenses If You choose to distribute Source Code Form that is 338 | Incompatible With Secondary Licenses under the terms of this version of 339 | the License, the notice described in Exhibit B of this License must be 340 | attached. 341 | 342 | Exhibit A - Source Code Form License Notice 343 | 344 | This Source Code Form is subject to the 345 | terms of the Mozilla Public License, v. 346 | 2.0. If a copy of the MPL was not 347 | distributed with this file, You can 348 | obtain one at 349 | http://mozilla.org/MPL/2.0/. 350 | 351 | If it is not possible or desirable to put the notice in a particular file, 352 | then You may include the notice in a location (such as a LICENSE file in a 353 | relevant directory) where a recipient would be likely to look for such a 354 | notice. 355 | 356 | You may add additional accurate notices of copyright ownership. 357 | 358 | Exhibit B - "Incompatible With Secondary Licenses" Notice 359 | 360 | This Source Code Form is "Incompatible 361 | With Secondary Licenses", as defined by 362 | the Mozilla Public License, v. 2.0. 363 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= 2 | al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= 4 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 5 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= 6 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 7 | github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= 8 | github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 9 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 12 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 13 | github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 14 | github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 19 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 20 | github.com/etherlabsio/healthcheck/v2 v2.0.0 h1:oKq8cbpwM/yNGPXf2Sff6MIjVUjx/pGYFydWzeK2MpA= 21 | github.com/etherlabsio/healthcheck/v2 v2.0.0/go.mod h1:huNVOjKzu6FI1eaO1CGD3ZjhrmPWf5Obu/pzpI6/wog= 22 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 23 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 24 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= 25 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 26 | github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= 27 | github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= 28 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 29 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 30 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 31 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 32 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 33 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 34 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 35 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 36 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 37 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 38 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 39 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 40 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 41 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 42 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 43 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 44 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 45 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 46 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 47 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 48 | github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= 49 | github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= 50 | github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= 51 | github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= 52 | github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= 53 | github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= 54 | github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= 55 | github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= 56 | github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= 57 | github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= 58 | github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= 59 | github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 60 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 61 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 62 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 63 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 64 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 65 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 66 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 67 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 68 | github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= 69 | github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= 70 | github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 71 | github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 72 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 73 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 74 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 75 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 76 | github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54= 77 | github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c= 78 | github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 79 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 80 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 81 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 82 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 83 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 84 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 85 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 86 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 87 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 88 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 89 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 90 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 91 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 92 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 93 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 94 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 95 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 96 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 97 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 98 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 99 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 100 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 101 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 102 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 103 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 104 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 105 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 108 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 109 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 110 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 111 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 112 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 113 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 114 | github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 115 | github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 116 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 117 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 118 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 119 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 120 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 121 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 122 | github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 123 | github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 124 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 125 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 126 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 127 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 128 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 129 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 130 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 131 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 132 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 133 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 134 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 135 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 136 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 137 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 138 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 139 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 140 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 141 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 142 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 143 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 144 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 145 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 146 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 147 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 148 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 149 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 150 | golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 151 | golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 152 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 153 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 154 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 155 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 156 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 157 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 158 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 159 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 160 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 161 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 162 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 163 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 164 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 167 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 168 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 169 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 170 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 171 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 172 | k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= 173 | k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= 174 | k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= 175 | k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= 176 | k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= 177 | k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= 178 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 179 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 180 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= 181 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 182 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 183 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 184 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 185 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 186 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= 187 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 188 | sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 189 | sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 190 | --------------------------------------------------------------------------------