├── .devcontainer.json ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── e2e.yml │ ├── release.yaml │ └── test.yml ├── .gitignore ├── .gitmodules ├── .goreleaser.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── sshpiperd │ ├── asciicast.go │ ├── cmd_dummy.go │ ├── cmd_linux.go │ ├── cmd_windows.go │ ├── daemon.go │ ├── grpc.go │ ├── hook.go │ ├── hook_test.go │ ├── internal │ └── plugin │ │ ├── chain.go │ │ ├── grpc.go │ │ └── tty.go │ ├── main.go │ ├── snap │ ├── README.md │ ├── configgen │ │ └── main.go │ ├── hooks │ │ └── configure │ └── launcher │ │ ├── doc.go │ │ ├── dummy.txt │ │ └── main.go │ └── typescript.go ├── e2e ├── banner_test.go ├── connmeta_test.go ├── docker-compose.yml ├── docker_test.go ├── e2eentry.sh ├── failtoban_test.go ├── fixed_test.go ├── grpcplugin_test.go ├── k8sworkload.yaml ├── kubernetes_test.go ├── kubetools │ └── Dockerfile ├── main_test.go ├── sshdconfig │ ├── banner │ ├── banner.conf │ ├── no_penalties.conf │ ├── trusted-ca.conf │ ├── trusted-ca.key │ └── trusted-ca.pub ├── testplugin │ ├── testgetmetaplugin │ │ └── main.go │ ├── testgrpcplugin │ │ └── main.go │ └── testsetmetaplugin │ │ └── main.go ├── workingdir_test.go └── yaml_test.go ├── go.mod ├── go.sum ├── libplugin ├── doc.go ├── ioconn │ ├── cmd.go │ ├── cmd_test.go │ ├── conn.go │ ├── conn_test.go │ ├── listener.go │ └── listener_test.go ├── plugin.pb.go ├── plugin.proto ├── plugin_grpc.pb.go ├── pluginbase.go ├── skel │ └── skel.go ├── template.go └── util.go └── plugin ├── docker ├── README.md ├── docker.go ├── main.go └── skel.go ├── failtoban ├── README.md └── main.go ├── fixed ├── README.md └── main.go ├── kubernetes ├── README.md ├── apis │ └── sshpiper │ │ └── v1beta1 │ │ ├── doc.go │ │ ├── register.go │ │ ├── types.go │ │ └── zz_generated.deepcopy.go ├── crd.yaml ├── generated │ ├── clientset │ │ └── versioned │ │ │ ├── clientset.go │ │ │ ├── fake │ │ │ ├── clientset_generated.go │ │ │ ├── doc.go │ │ │ └── register.go │ │ │ ├── scheme │ │ │ ├── doc.go │ │ │ └── register.go │ │ │ └── typed │ │ │ └── sshpiper │ │ │ └── v1beta1 │ │ │ ├── doc.go │ │ │ ├── fake │ │ │ ├── doc.go │ │ │ ├── fake_pipe.go │ │ │ └── fake_sshpiper_client.go │ │ │ ├── generated_expansion.go │ │ │ ├── pipe.go │ │ │ └── sshpiper_client.go │ ├── informers │ │ └── externalversions │ │ │ ├── factory.go │ │ │ ├── generic.go │ │ │ ├── internalinterfaces │ │ │ └── factory_interfaces.go │ │ │ └── sshpiper │ │ │ ├── interface.go │ │ │ └── v1beta1 │ │ │ ├── interface.go │ │ │ └── pipe.go │ └── listers │ │ └── sshpiper │ │ └── v1beta1 │ │ ├── expansion_generated.go │ │ └── pipe.go ├── kubernetes.go ├── main.go ├── sample.yaml ├── skel.go ├── tools.go └── update-codegen.sh ├── simplemath ├── README.md └── main.go ├── username-router ├── README.md └── main.go ├── workingdir ├── README.md ├── main.go ├── skel.go └── workingdir.go └── yaml ├── README.md ├── main.go ├── schema.json ├── skel.go ├── yaml.go └── yaml_test.go /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e dev env", 3 | "dockerComposeFile": "e2e/docker-compose.yml", 4 | "service": "testrunner", 5 | "containerEnv": { 6 | "SSHPIPERD_DEBUG": "1" 7 | }, 8 | "workspaceFolder": "/src", 9 | "features": { 10 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, 11 | "ghcr.io/devcontainers/features/go:1" : {}, 12 | "ghcr.io/guiyomh/features/golangci-lint:0": {}, 13 | "ghcr.io/devcontainers-contrib/features/kubectl-asdf:2": {} 14 | }, 15 | "customizations": { 16 | "vscode": { 17 | "extensions": [ 18 | "GitHub.copilot" 19 | ] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tg123 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | 9 | # Maintain dependencies for GitHub Actions 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | - package-ecosystem: "gomod" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | 11 | jobs: 12 | 13 | E2E: 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.event.workflow_run.head_sha }} 21 | submodules: 'recursive' 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Kind Warmup 27 | run: kind create cluster -n sshpipertest 28 | 29 | - name: E2E 30 | run: docker compose up --build --abort-on-container-exit --exit-code-from testrunner 31 | working-directory: e2e 32 | env: 33 | COMPOSE_DOCKER_CLI_BUILD: "1" 34 | DOCKER_BUILDKIT: "1" 35 | 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | release: 7 | types: [ released ] 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | 15 | draft: 16 | runs-on: ubuntu-latest 17 | if: github.event_name != 'release' 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.workflow_run.head_sha }} 23 | fetch-depth: 0 24 | submodules: 'recursive' 25 | 26 | - name: Repo SemVer 27 | uses: lhstrh/action-repo-semver@v1.2.1 28 | id: repo-semver 29 | with: 30 | bump: patch 31 | 32 | - name: Create draft release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: | 36 | gh release list | grep Draft | grep v${{ steps.repo-semver.outputs.bump }} | cut -f 1 | xargs -r -L 1 gh release delete --yes 37 | gh release create -d --generate-notes v${{ steps.repo-semver.outputs.bump }} 38 | 39 | goreleaser: 40 | runs-on: ubuntu-latest 41 | if: github.event_name == 'release' 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4 45 | with: 46 | ref: ${{ github.event.workflow_run.head_sha }} 47 | fetch-depth: 0 48 | submodules: 'recursive' 49 | 50 | - name: Set up QEMU 51 | uses: docker/setup-qemu-action@v3 52 | 53 | - name: Set up Docker Buildx 54 | uses: docker/setup-buildx-action@v3 55 | 56 | - name: Log into registry docker hub 57 | uses: docker/login-action@v3 58 | with: 59 | username: ${{ secrets.DOCKERHUB_USERNAME }} 60 | password: ${{ secrets.DOCKERHUB_TOKEN }} 61 | 62 | - name: Log in to the Container registry 63 | uses: docker/login-action@v3 64 | with: 65 | registry: ghcr.io 66 | username: ${{ github.actor }} 67 | password: ${{ secrets.GITHUB_TOKEN }} 68 | 69 | - name: Set up Go 1.x 70 | uses: actions/setup-go@v5 71 | with: 72 | go-version: 'stable' 73 | cache: true 74 | 75 | - name: Setup Snap 76 | run: | 77 | set -e 78 | sudo snap install snapcraft --classic --revision 11040 79 | # https://github.com/goreleaser/goreleaser/pull/2117 80 | mkdir -p $HOME/.cache/snapcraft/download 81 | mkdir -p $HOME/.cache/snapcraft/stage-packages 82 | 83 | - name: Run GoReleaser 84 | uses: goreleaser/goreleaser-action@v6 85 | with: 86 | distribution: goreleaser 87 | version: latest 88 | args: release --clean 89 | env: 90 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | SKIP_PUSH: ${{ github.event_name != 'release' }} 92 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 93 | 94 | - name: Upload binaries to release 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | run: | 98 | awk '{print "dist/"$2}' dist/checksums.txt | xargs gh release upload ${{github.event.release.name}} dist/checksums.txt 99 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go Unit Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | 13 | test-and-lint: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v4 19 | with: 20 | submodules: 'recursive' 21 | 22 | - name: Set up Go 1.x 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: 'stable' 26 | cache: true 27 | 28 | - name: Test lib ssh 29 | run: go test -v -cover ./... 30 | working-directory: crypto/ssh 31 | 32 | - name: Test sshpiper 33 | run: go test -v -race -cover -tags full ./... 34 | 35 | - name: golangci-lint 36 | uses: golangci/golangci-lint-action@v8 37 | with: 38 | args: --timeout=60m --verbose --print-resources-usage --build-tags full -D errcheck 39 | env: 40 | GOGC: "10" 41 | 42 | 43 | # test go releaser 44 | - name: Set up QEMU 45 | uses: docker/setup-qemu-action@v3 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | 50 | - name: Setup Snap 51 | run: | 52 | set -e 53 | sudo snap install snapcraft --classic --revision 11040 54 | # https://github.com/goreleaser/goreleaser/pull/2117 55 | mkdir -p $HOME/.cache/snapcraft/download 56 | mkdir -p $HOME/.cache/snapcraft/stage-packages 57 | 58 | - name: Run GoReleaser 59 | uses: goreleaser/goreleaser-action@v6 60 | with: 61 | distribution: goreleaser 62 | version: latest 63 | args: release --snapshot --clean 64 | env: 65 | SKIP_PUSH: "true" 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | dist/ 24 | cmd/sshpiperd/snap/launcher/configentry.txt -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "crypto"] 2 | path = crypto 3 | url = https://github.com/tg123/sshpiper.crypto 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to sshpiper. 4 | Make sure you have read [README.md](README.md) before starting. 5 | 6 | ## Getting Started 7 | 8 | ### Software Requirements 9 | * Go 10 | * Docker 11 | * Docker Compose 12 | * Git 13 | 14 | ### Get the code 15 | 16 | rememeber to clone the submodules 17 | 18 | ``` 19 | git clone https://github.com/tg123/sshpiper 20 | cd sshpiper 21 | git submodule update --init --recursive 22 | ``` 23 | 24 | ### Start Develop Environment 25 | 26 | _Note_: in vscode, you can use Reopen in dev container to start the develop environment. 27 | 28 | ``` 29 | # in e2e folder, run: 30 | SSHPIPERD_DEBUG=1 docker-compose up --force-recreate --build -d 31 | ``` 32 | 33 | you will have two sshd: 34 | 35 | * `host-password:2222`: a password only sshd server (user: `user`, password: `pass`) 36 | * `host-publickey:2222`: a public key only sshd server (put your public key in `/publickey_authorized_keys/authorized_keys`) 37 | 38 | more settings: 39 | 40 | ### Make some direct changes to source code 41 | 42 | after you have done, attach to testrunner container: 43 | 44 | ``` 45 | docker exec -ti e2e_testrunner_1 bash 46 | ``` 47 | 48 | then run test in `/src/e2e` 49 | 50 | ``` 51 | go test 52 | ``` 53 | 54 | ### Send PR with Github 55 | 56 | 57 | 58 | ## Understanding how sshpiper works 59 | 60 | ### sshpiper seasoned cryto ssh lib 61 | 62 | The `crypto` folder contains the source code of the [sshpiper seasoned cryto ssh lib](./crypto/). 63 | It based on [crypto/ssh](https://golang.org/pkg/crypto/ssh/) and with a drop-in [sshpiper.go](./crypto/ssh/sshpiper.go) to expose all low level sshpiper required APIs. 64 | 65 | ### sshpiperd 66 | 67 | [sshpiperd](./cmd/sshpiperd/) is the daemon wraps the `crypto/ssh` library to provide ssh connections management. 68 | It accepts ssh connections from `downstream` and routes them to `upstream`. 69 | The plugins are responsible to figure out how to authenticate `downstream` and map it to `upstream` 70 | 71 | ### plugin 72 | 73 | The plugin is typically a grpc server that accepts requrests from `sshpiperd`. 74 | The proto defines in [sshpiper.proto](./proto/sshpiper.proto). 75 | 76 | In most of the cases, the plugin connects with `sshpiperd` via `stdin/stdout`. The [ioconnn](./libplugin/ioconn/) wraps stdin/stdout to net.Conn for grpc use. 77 | `sshpiperd` also supports to create remote grpc connections to a plugin deploy in a different machine. 78 | 79 | ## Your first plugin 80 | 81 | [fixed](./plugin/fixed/) and [simplematch](./plugin/simplematch/) are two good examples of plugins. 82 | They are very simple and just less than 50 lines of code. 83 | 84 | Take `fixed` as an example: 85 | 86 | ``` 87 | &libplugin.SshPiperPluginConfig{ 88 | PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { 89 | return &libplugin.Upstream{ 90 | Host: host, 91 | Port: int32(port), 92 | IgnoreHostKey: true, 93 | Auth: libplugin.CreatePasswordAuth(password), 94 | }, nil 95 | }, 96 | } 97 | ``` 98 | 99 | Here means the `downstream` is sending password to `sshpiperd`. Then `sshpiperd` will call plugin's `PasswordCallback` to get the `upstream` to connect to. 100 | The `upstream` object contains host port and auth info about how to connect to the `upstream`. you can aslo return an error to deny the connection. 101 | 102 | ### build and run the plugin 103 | 104 | simple build it with: 105 | 106 | ``` 107 | go build -tags full 108 | ``` 109 | 110 | you will get the executable in the current directory. say `myplugin`. start it with: 111 | 112 | ``` 113 | sshpiperd /path/to/myplugin 114 | ``` 115 | 116 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/golang:1.24-bookworm AS builder 2 | ARG VER=devel 3 | ARG BUILDTAGS 4 | ARG EXTERNAL=0 5 | ENV CGO_ENABLED=0 6 | WORKDIR /src 7 | 8 | RUN \ 9 | --mount=target=/src,type=bind,source=. \ 10 | --mount=type=cache,target=/root/.cache/go-build \ 11 | <= len(cp.pluginsCallback) { 45 | return fmt.Errorf("no more plugins") 46 | } 47 | 48 | chain.current++ 49 | return nil 50 | } 51 | 52 | type chainConnMeta struct { 53 | PluginConnMeta 54 | current int 55 | } 56 | 57 | func (cp *ChainPlugins) CreateChallengeContext(conn ssh.ServerPreAuthConn) (ssh.ChallengeContext, error) { 58 | uiq, err := uuid.NewRandom() 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | meta := chainConnMeta{ 64 | PluginConnMeta: PluginConnMeta{ 65 | UserName: conn.User(), 66 | FromAddr: conn.RemoteAddr().String(), 67 | UniqId: uiq.String(), 68 | Metadata: make(map[string]string), 69 | }, 70 | } 71 | 72 | for _, p := range cp.plugins { 73 | if err := p.NewConnection(&meta.PluginConnMeta); err != nil { 74 | return nil, err 75 | } 76 | } 77 | 78 | return &meta, nil 79 | } 80 | 81 | func (cp *ChainPlugins) NextAuthMethods(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) ([]string, error) { 82 | chain := challengeCtx.(*chainConnMeta) 83 | config := cp.pluginsCallback[chain.current] 84 | 85 | if config.NextAuthMethods != nil { 86 | return config.NextAuthMethods(conn, challengeCtx) 87 | } 88 | 89 | var methods []string 90 | 91 | if config.NoClientAuthCallback != nil { 92 | methods = append(methods, "none") 93 | } 94 | 95 | if config.PasswordCallback != nil { 96 | methods = append(methods, "password") 97 | } 98 | 99 | if config.PublicKeyCallback != nil { 100 | methods = append(methods, "publickey") 101 | } 102 | 103 | if config.KeyboardInteractiveCallback != nil { 104 | methods = append(methods, "keyboard-interactive") 105 | } 106 | 107 | log.Debugf("next auth methods %v", methods) 108 | return methods, nil 109 | } 110 | 111 | func (cp *ChainPlugins) InstallPiperConfig(config *GrpcPluginConfig) error { 112 | 113 | config.CreateChallengeContext = func(conn ssh.ServerPreAuthConn) (ssh.ChallengeContext, error) { 114 | ctx, err := cp.CreateChallengeContext(conn) 115 | if err != nil { 116 | log.Errorf("cannot create challenge context %v", err) 117 | } 118 | return ctx, err 119 | } 120 | 121 | config.NextAuthMethods = cp.NextAuthMethods 122 | 123 | config.NoClientAuthCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) (*ssh.Upstream, error) { 124 | return cp.pluginsCallback[challengeCtx.(*chainConnMeta).current].NoClientAuthCallback(conn, challengeCtx) 125 | } 126 | 127 | config.PasswordCallback = func(conn ssh.ConnMetadata, password []byte, challengeCtx ssh.ChallengeContext) (*ssh.Upstream, error) { 128 | return cp.pluginsCallback[challengeCtx.(*chainConnMeta).current].PasswordCallback(conn, password, challengeCtx) 129 | } 130 | 131 | config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey, challengeCtx ssh.ChallengeContext) (*ssh.Upstream, error) { 132 | return cp.pluginsCallback[challengeCtx.(*chainConnMeta).current].PublicKeyCallback(conn, key, challengeCtx) 133 | } 134 | 135 | config.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge, challengeCtx ssh.ChallengeContext) (*ssh.Upstream, error) { 136 | return cp.pluginsCallback[challengeCtx.(*chainConnMeta).current].KeyboardInteractiveCallback(conn, client, challengeCtx) 137 | } 138 | 139 | config.UpstreamAuthFailureCallback = func(conn ssh.ConnMetadata, method string, err error, challengeCtx ssh.ChallengeContext) { 140 | for _, p := range cp.pluginsCallback { 141 | if p.UpstreamAuthFailureCallback != nil { 142 | p.UpstreamAuthFailureCallback(conn, method, err, challengeCtx) 143 | } 144 | } 145 | } 146 | 147 | config.DownstreamBannerCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) string { 148 | cur := cp.pluginsCallback[challengeCtx.(*chainConnMeta).current] 149 | if cur.DownstreamBannerCallback != nil { 150 | return cur.DownstreamBannerCallback(conn, challengeCtx) 151 | } 152 | 153 | return "" 154 | } 155 | 156 | config.PipeStartCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) { 157 | for _, p := range cp.pluginsCallback { 158 | if p.PipeStartCallback != nil { 159 | p.PipeStartCallback(conn, challengeCtx) 160 | } 161 | } 162 | } 163 | 164 | config.PipeErrorCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext, err error) { 165 | for _, p := range cp.pluginsCallback { 166 | if p.PipeErrorCallback != nil { 167 | p.PipeErrorCallback(conn, challengeCtx, err) 168 | } 169 | } 170 | } 171 | 172 | config.PipeCreateErrorCallback = func(conn net.Conn, err error) { 173 | for _, p := range cp.pluginsCallback { 174 | if p.PipeCreateErrorCallback != nil { 175 | p.PipeCreateErrorCallback(conn, err) 176 | } 177 | } 178 | } 179 | 180 | return nil 181 | } 182 | -------------------------------------------------------------------------------- /cmd/sshpiperd/internal/plugin/tty.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "golang.org/x/term" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // checkIfTerminal returns whether the given file descriptor is a terminal. 10 | func checkIfTerminal(w io.Writer) bool { 11 | switch v := w.(type) { 12 | case *os.File: 13 | return term.IsTerminal(int(v.Fd())) 14 | default: 15 | return false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/README.md: -------------------------------------------------------------------------------- 1 | # Snap for sshpiperd 2 | 3 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-white.svg)](https://snapcraft.io/sshpiperd) 4 | 5 | ## Install 6 | 7 | ``` 8 | sudo snap install sshpiperd 9 | ``` 10 | 11 | ## Config 12 | 13 | ``` 14 | sudo snap set sshpiperd = 15 | sudo snap restart sshpiperd 16 | ``` 17 | 18 | ### sshpiperd 19 | 20 | * `sshpiperd.plugins` space separated list of plugins, allowed values: `workingdir`, `fixed`, `yaml` `failtoban`, 21 | * `sshpiperd.address` listening address 22 | * `sshpiperd.port` listening port 23 | * `sshpiperd.server-key` server key files, support wildcard 24 | * `sshpiperd.server-key-data` server key in base64 format, server-key, server-key-generate-mode will be ignored if set 25 | * `sshpiperd.server-key-generate-mode` server key generate mode, one of: disable, notexist, always. generated key will be written to `server-key` if * no`texist or always 26 | * `sshpiperd.login-grace-time` sshpiperd forcely close the connection after this time if the pipe has not successfully established 27 | * `sshpiperd.log-level` log level, one of: trace, debug, info, warn, error, fatal, panic 28 | * `sshpiperd.typescript-log-dir` create typescript format screen recording and save into the directory see https://linux.die.net/man/1/script 29 | * `sshpiperd.banner-text` display a banner before authentication, would be ignored if banner file was set 30 | * `sshpiperd.banner-file` display a banner from file before authentication 31 | * `sshpiperd.drop-hostkeys-message` filter out hostkeys-00@openssh.com which cause client side warnings 32 | 33 | ### workingdir plugin 34 | 35 | * `workingdir.root path` to root working directory 36 | * `workingdir.allow-baduser-name` allow bad username 37 | * `workingdir.no-check-perm` disable 0400 checking 38 | * `workingdir.strict-hostkey` upstream host public key must be in known_hosts file, otherwise drop the connection 39 | * `workingdir.no-password-auth` disable password authentication and only use public key authentication 40 | 41 | ### yaml plugin 42 | 43 | * `yaml.config` path to yaml config file 44 | * `yaml.no-check-perm` disable 0400 checking 45 | 46 | ### fixed plugin 47 | 48 | * `fixed.target` target ssh endpoint address 49 | 50 | ### failtoban plugin 51 | 52 | * `failtoban.max-failures` max failures 53 | * `failtoban.ban-duration` ban duration 54 | 55 | -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/configgen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "log" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | func main() { 14 | 15 | configs := map[string]string{ 16 | "sshpiperd": "../../main.go", 17 | "workingdir": "../../../../plugin/workingdir/main.go", 18 | "yaml": "../../../../plugin/yaml/main.go", 19 | "fixed": "../../../../plugin/fixed/main.go", 20 | "failtoban": "../../../../plugin/failtoban/main.go", 21 | } 22 | 23 | for k, v := range configs { 24 | extractFlags(k, v) 25 | } 26 | } 27 | 28 | func extractFlags(namespace, filePath string) { 29 | file, err := os.Open(filePath) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | defer file.Close() 34 | 35 | fset := token.NewFileSet() 36 | 37 | node, err := parser.ParseFile(fset, filePath, file, parser.AllErrors) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | ast.Inspect(node, func(n ast.Node) bool { 43 | 44 | if cl, ok := n.(*ast.CompositeLit); ok { 45 | 46 | if t, ok := cl.Type.(*ast.SelectorExpr); ok { 47 | 48 | o, ok := t.X.(*ast.Ident) 49 | if !ok { 50 | return true 51 | } 52 | 53 | if o.Name != "cli" { 54 | return true 55 | } 56 | 57 | if !strings.HasSuffix(t.Sel.Name, "Flag") { 58 | return true 59 | } 60 | 61 | var flagName string 62 | var flagDesc string 63 | 64 | for _, v := range cl.Elts { 65 | if kv, ok := v.(*ast.KeyValueExpr); ok { 66 | 67 | switch kv.Key.(*ast.Ident).Name { 68 | case "Name": 69 | flagName = strings.Trim(kv.Value.(*ast.BasicLit).Value, " \"") 70 | case "Usage": 71 | flagDesc = strings.Trim(kv.Value.(*ast.BasicLit).Value, " \"") 72 | } 73 | } 74 | } 75 | 76 | fmt.Printf("%v.%v %v\n", namespace, flagName, flagDesc) 77 | } 78 | } 79 | return true 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/hooks/configure: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | $SNAP/launcher generate 5 | -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/launcher/doc.go: -------------------------------------------------------------------------------- 1 | //go:generate sh -c "go run ../configgen/main.go > configentry.txt" 2 | package main 3 | -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/launcher/dummy.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tg123/sshpiper/afd80aa5906f5fcc10b95c78652fbe445b299c74/cmd/sshpiperd/snap/launcher/dummy.txt -------------------------------------------------------------------------------- /cmd/sshpiperd/snap/launcher/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "strings" 11 | ) 12 | 13 | //go:embed all:*.txt 14 | var configentry embed.FS 15 | 16 | func main() { 17 | bindir := os.Getenv("SNAP") 18 | datadir := os.Getenv("SNAP_DATA") 19 | 20 | flags := map[string][][]string{} 21 | configfile := path.Join(datadir, "flags.json") 22 | 23 | if len(os.Args) > 1 && os.Args[1] == "generate" { 24 | flags = loadFromSnapctl() 25 | cache, _ := json.Marshal(flags) 26 | if err := os.WriteFile(configfile, cache, 0600); err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | return 31 | } 32 | 33 | cache, _ := os.ReadFile(configfile) 34 | _ = json.Unmarshal(cache, &flags) 35 | 36 | cmd := exec.Command(path.Join(bindir, "sshpiperd")) 37 | cmd.Stdin = os.Stdin 38 | cmd.Stdout = os.Stdout 39 | cmd.Stderr = os.Stderr 40 | 41 | for _, flag := range flags["sshpiperd"] { 42 | cmd.Args = append(cmd.Args, "--"+flag[0], flag[1]) 43 | } 44 | 45 | for _, plugin := range flags["sshpiperd.plugins"][0] { 46 | cmd.Args = append(cmd.Args, path.Join(bindir, plugin)) 47 | for _, flag := range flags[plugin] { 48 | cmd.Args = append(cmd.Args, "--"+flag[0], flag[1]) 49 | } 50 | cmd.Args = append(cmd.Args, "--") 51 | } 52 | 53 | log.Println("starting sshpiperd with args:", cmd) 54 | _ = cmd.Run() 55 | } 56 | 57 | func loadFromSnapctl() map[string][][]string { 58 | commondir := os.Getenv("SNAP_COMMON") 59 | 60 | flags := map[string][][]string{} 61 | 62 | data, err := configentry.ReadFile("configentry.txt") 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | for _, line := range strings.Split(string(data), "\n") { 68 | line = strings.TrimSpace(line) 69 | if line == "" { 70 | continue 71 | } 72 | 73 | parts := strings.Split(line, " ") 74 | if len(parts) == 0 { 75 | continue 76 | } 77 | 78 | opt := parts[0] 79 | v, err := get(opt) 80 | if err != nil { 81 | log.Printf("error getting %v: %v", line, err) 82 | } 83 | 84 | if v == "" { 85 | continue 86 | } 87 | 88 | parts = strings.Split(opt, ".") 89 | ns := parts[0] 90 | flag := parts[1] 91 | 92 | flags[ns] = append(flags[ns], []string{flag, v}) 93 | } 94 | 95 | // known defaults 96 | { 97 | v, _ := get("sshpiperd.plugins") 98 | if v == "" { 99 | v = "workingdir" 100 | } 101 | 102 | var plugins []string 103 | for _, str := range strings.Split(v, " ") { 104 | str = strings.TrimSpace(str) 105 | if str != "" { 106 | plugins = append(plugins, str) 107 | } 108 | } 109 | 110 | flags["sshpiperd.plugins"] = [][]string{plugins} 111 | } 112 | 113 | // { 114 | // v, _ := get("sshpiperd.typescript-log-dir") 115 | // if v == "" { 116 | // v = "screenrecord" 117 | // dir := path.Join(commondir, v) 118 | // _ = os.MkdirAll(dir, 0700) 119 | // flags["sshpiperd"] = append(flags["sshpiperd"], []string{"typescript-log-dir", dir}) 120 | // } 121 | // } 122 | 123 | { 124 | v, _ := get("sshpiperd.server-key-generate-mode") 125 | if v == "" { 126 | v = "notexist" 127 | flags["sshpiperd"] = append(flags["sshpiperd"], []string{"server-key-generate-mode", v}) 128 | } 129 | } 130 | 131 | { 132 | v, _ := get("sshpiperd.server-key") 133 | if v == "" { 134 | v = "ssh_host_ed25519_key" 135 | file := path.Join(commondir, v) 136 | flags["sshpiperd"] = append(flags["sshpiperd"], []string{"server-key", file}) 137 | } 138 | } 139 | 140 | { 141 | v, _ := get("workingdir.root") 142 | if v == "" { 143 | v = "workingdir" 144 | 145 | dir := path.Join(commondir, v) 146 | _ = os.MkdirAll(dir, 0700) 147 | flags["workingdir"] = append(flags["workingdir"], []string{"root", dir}) 148 | } 149 | } 150 | 151 | return flags 152 | } 153 | 154 | func get(key string) (string, error) { 155 | cmd := exec.Command("snapctl", "get", key) 156 | output, err := cmd.Output() 157 | if err != nil { 158 | return "", err 159 | } 160 | 161 | value := string(output) 162 | return strings.TrimSpace(value), nil 163 | } 164 | -------------------------------------------------------------------------------- /cmd/sshpiperd/typescript.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "time" 8 | ) 9 | 10 | const ( 11 | msgChannelData = 94 12 | ) 13 | 14 | type filePtyLogger struct { 15 | typescript *os.File 16 | timing *os.File 17 | 18 | oldtime time.Time 19 | } 20 | 21 | func newFilePtyLogger(outputdir string) (*filePtyLogger, error) { 22 | 23 | now := time.Now() 24 | 25 | filename := fmt.Sprintf("%d", now.Unix()) 26 | 27 | typescript, err := os.OpenFile(path.Join(outputdir, fmt.Sprintf("%v.typescript", filename)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 28 | 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | _, err = fmt.Fprintf(typescript, "Script started on %v\n", now.Format(time.ANSIC)) 34 | 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | timing, err := os.OpenFile(path.Join(outputdir, fmt.Sprintf("%v.timing", filename)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 40 | 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &filePtyLogger{ 46 | typescript: typescript, 47 | timing: timing, 48 | oldtime: time.Now(), 49 | }, nil 50 | } 51 | 52 | func (l *filePtyLogger) loggingTty(msg []byte) error { 53 | 54 | if msg[0] == msgChannelData { 55 | 56 | buf := msg[9:] 57 | 58 | now := time.Now() 59 | 60 | delta := now.Sub(l.oldtime) 61 | 62 | // see term-utils/script.c 63 | fmt.Fprintf(l.timing, "%v.%06v %v\n", int64(delta/time.Second), int64(delta%time.Second/time.Microsecond), len(buf)) 64 | 65 | l.oldtime = now 66 | 67 | _, err := l.typescript.Write(buf) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (l *filePtyLogger) Close() (err error) { 79 | // if _, err = ; err != nil { 80 | // return err 81 | // } 82 | _, _ = fmt.Fprintf(l.typescript, "Script done on %v\n", time.Now().Format(time.ANSIC)) 83 | 84 | l.typescript.Close() 85 | l.timing.Close() 86 | 87 | return nil // TODO 88 | } 89 | -------------------------------------------------------------------------------- /e2e/banner_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func TestBanner(t *testing.T) { 11 | 12 | t.Run("args", func(t *testing.T) { 13 | piperaddr, piperport := nextAvailablePiperAddress() 14 | randtext := uuid.New().String() 15 | 16 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 17 | "--banner-text", 18 | randtext, 19 | "-p", 20 | piperport, 21 | "/sshpiperd/plugins/fixed", 22 | "--target", 23 | "host-password:2222", 24 | ) 25 | 26 | if err != nil { 27 | t.Errorf("failed to run sshpiperd: %v", err) 28 | } 29 | 30 | defer killCmd(piper) 31 | 32 | waitForEndpointReady(piperaddr) 33 | 34 | c, _, stdout, err := runCmd( 35 | "ssh", 36 | "-v", 37 | "-o", 38 | "StrictHostKeyChecking=no", 39 | "-o", 40 | "UserKnownHostsFile=/dev/null", 41 | "-p", 42 | piperport, 43 | "-l", 44 | "user", 45 | "127.0.0.1", 46 | ) 47 | 48 | if err != nil { 49 | t.Errorf("failed to ssh to piper, %v", err) 50 | } 51 | 52 | defer killCmd(c) 53 | 54 | waitForStdoutContains(stdout, randtext, func(_ string) { 55 | }) 56 | }) 57 | 58 | t.Run("file", func(t *testing.T) { 59 | 60 | piperaddr, piperport := nextAvailablePiperAddress() 61 | randtext := uuid.New().String() 62 | 63 | bannerfile, err := os.CreateTemp("", "banner") 64 | if err != nil { 65 | t.Errorf("failed to create temp file: %v", err) 66 | } 67 | defer os.Remove(bannerfile.Name()) 68 | 69 | if _, err := bannerfile.WriteString(randtext); err != nil { 70 | t.Errorf("failed to write to temp file: %v", err) 71 | } 72 | 73 | if err := bannerfile.Close(); err != nil { 74 | t.Errorf("failed to close temp file: %v", err) 75 | } 76 | 77 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 78 | "--banner-file", 79 | bannerfile.Name(), 80 | "-p", 81 | piperport, 82 | "/sshpiperd/plugins/fixed", 83 | "--target", 84 | "host-password:2222", 85 | ) 86 | 87 | if err != nil { 88 | t.Errorf("failed to run sshpiperd: %v", err) 89 | } 90 | 91 | defer killCmd(piper) 92 | 93 | waitForEndpointReady(piperaddr) 94 | 95 | c, _, stdout, err := runCmd( 96 | "ssh", 97 | "-v", 98 | "-o", 99 | "StrictHostKeyChecking=no", 100 | "-o", 101 | "UserKnownHostsFile=/dev/null", 102 | "-p", 103 | piperport, 104 | "-l", 105 | "user", 106 | "127.0.0.1", 107 | ) 108 | 109 | if err != nil { 110 | t.Errorf("failed to ssh to piper, %v", err) 111 | } 112 | 113 | defer killCmd(c) 114 | 115 | waitForStdoutContains(stdout, randtext, func(_ string) { 116 | }) 117 | 118 | t.Run("from_upstream", func(t *testing.T) { 119 | piperaddr, piperport := nextAvailablePiperAddress() 120 | 121 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 122 | "-p", 123 | piperport, 124 | "/sshpiperd/plugins/fixed", 125 | "--target", 126 | "host-password:2222", 127 | ) 128 | 129 | if err != nil { 130 | t.Errorf("failed to run sshpiperd: %v", err) 131 | } 132 | 133 | defer killCmd(piper) 134 | 135 | waitForEndpointReady(piperaddr) 136 | 137 | c, stdin, stdout, err := runCmd( 138 | "ssh", 139 | "-v", 140 | "-o", 141 | "StrictHostKeyChecking=no", 142 | "-o", 143 | "UserKnownHostsFile=/dev/null", 144 | "-p", 145 | piperport, 146 | "-l", 147 | "user", 148 | "127.0.0.1", 149 | ) 150 | 151 | if err != nil { 152 | t.Errorf("failed to ssh to piper, %v", err) 153 | } 154 | 155 | defer killCmd(c) 156 | 157 | enterPassword(stdin, stdout, "wrongpass") 158 | 159 | waitForStdoutContains(stdout, "sshpiper banner from upstream test", func(_ string) { 160 | }) 161 | }) 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /e2e/connmeta_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | func TestConnMeta(t *testing.T) { 12 | piperaddr, piperport := nextAvailablePiperAddress() 13 | 14 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 15 | "-p", 16 | piperport, 17 | "/sshpiperd/plugins/testsetmetaplugin", 18 | "--targetaddr", 19 | "host-password:2222", 20 | "--", 21 | "/sshpiperd/plugins/testgetmetaplugin", 22 | ) 23 | 24 | if err != nil { 25 | t.Errorf("failed to run sshpiperd: %v", err) 26 | } 27 | 28 | defer killCmd(piper) 29 | 30 | waitForEndpointReady(piperaddr) 31 | 32 | randtext := uuid.New().String() 33 | targetfie := uuid.New().String() 34 | 35 | c, stdin, stdout, err := runCmd( 36 | "ssh", 37 | "-v", 38 | "-o", 39 | "StrictHostKeyChecking=no", 40 | "-o", 41 | "UserKnownHostsFile=/dev/null", 42 | "-p", 43 | piperport, 44 | "-l", 45 | "user", 46 | "127.0.0.1", 47 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 48 | ) 49 | 50 | if err != nil { 51 | t.Errorf("failed to ssh to piper-fixed, %v", err) 52 | } 53 | 54 | defer killCmd(c) 55 | 56 | enterPassword(stdin, stdout, "pass") 57 | 58 | time.Sleep(time.Second) // wait for file flush 59 | 60 | checkSharedFileContent(t, targetfie, randtext) 61 | } 62 | -------------------------------------------------------------------------------- /e2e/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | host-password: 3 | image: linuxserver/openssh-server:9.9_p1-r2-ls190 4 | environment: 5 | - PASSWORD_ACCESS=true 6 | - USER_PASSWORD=pass 7 | - USER_NAME=user 8 | - LOG_STDOUT=true 9 | labels: 10 | - sshpiper.username=pass 11 | - sshpiper.container_username=user 12 | - sshpiper.port=2222 13 | - sshpiper.network=e2e_default 14 | volumes: 15 | - shared:/shared 16 | - ./sshdconfig/no_penalties.conf:/config/sshd/sshd_config.d/no_penalties.conf:ro 17 | - ./sshdconfig/banner.conf:/config/sshd/sshd_config.d/banner.conf:ro 18 | - ./sshdconfig/banner:/tmp/banner:ro 19 | networks: 20 | - default 21 | - netdistract 22 | 23 | host-password-old: 24 | image: linuxserver/openssh-server:8.1_p1-r0-ls19 25 | environment: 26 | - PASSWORD_ACCESS=true 27 | - USER_PASSWORD=pass 28 | - USER_NAME=user 29 | - LOG_STDOUT=true 30 | volumes: 31 | - shared:/shared 32 | networks: 33 | - default 34 | - netdistract 35 | 36 | host-publickey: 37 | image: linuxserver/openssh-server:9.9_p1-r2-ls190 38 | environment: 39 | - USER_NAME=user 40 | - LOG_STDOUT=true 41 | labels: 42 | - sshpiper.container_username=user 43 | - sshpiper.port=2222 44 | - sshpiper.authorized_keys=c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSU5SR1RIMzI1ckRVcDEydHBsd3VrSG1SOHl0YkM5VFBaODg2Z0NzdHluUDEgdGVzdEB0ZXN0Cg== 45 | - sshpiper.private_key=LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNEVVJreDk5dWF3MUtkZHJhWmNMcEI1a2ZNcld3dlV6MmZQT29BckxjcHo5UUFBQUpDK2owK1N2bzlQCmtnQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDRFVSa3g5OXVhdzFLZGRyYVpjTHBCNWtmTXJXd3ZVejJmUE9vQXJMY3B6OVEKQUFBRURjUWdkaDJ6MnIvNmJscTB6aUoxbDZzNklBWDhDKzlRSGZBSDkzMWNITk85UkdUSDMyNXJEVXAxMnRwbHd1a0htUgo4eXRiQzlUUFo4ODZnQ3N0eW5QMUFBQUFEV0p2YkdsaGJrQjFZblZ1ZEhVPQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K 46 | volumes: 47 | - shared:/shared 48 | - publickey_authorized_keys:/config/.ssh/ 49 | - ./sshdconfig/no_penalties.conf:/config/sshd/sshd_config.d/no_penalties.conf:ro 50 | 51 | host-capublickey: 52 | image: linuxserver/openssh-server:9.9_p1-r2-ls190 53 | environment: 54 | - USER_NAME=ca_user 55 | - LOG_STDOUT=true 56 | volumes: 57 | - shared:/shared 58 | - ./sshdconfig/no_penalties.conf:/config/sshd/sshd_config.d/no_penalties.conf:ro 59 | - ./sshdconfig/trusted-ca.conf:/config/sshd/sshd_config.d/trusted-ca.conf:ro 60 | - ./sshdconfig/trusted-ca.pub:/config/sshd/trusted-ca.pub:ro 61 | 62 | 63 | host-k8s-proxy: 64 | build: ./kubetools 65 | volumes: 66 | - /var/run/docker.sock:/var/run/docker.sock 67 | - ../plugin/kubernetes/crd.yaml:/kubernetes/crd.yaml:ro 68 | - ./k8sworkload.yaml:/kubernetes/workload.yaml:ro 69 | - kubeconfig:/root/.kube 70 | # networks: 71 | # - kind 72 | # - default 73 | command: 74 | - bash 75 | - -cx 76 | - | 77 | (kind get kubeconfig -q -n sshpipertest || kind create cluster -n sshpipertest) 78 | docker network connect kind $$(hostname) # self contain 79 | docker network connect e2e_default sshpipertest-control-plane 80 | kind export kubeconfig -n sshpipertest --internal 81 | kubectl wait --for=condition=ready pod -n kube-system --all --timeout=2m 82 | kubectl delete -f /kubernetes/crd.yaml --force --ignore-not-found 83 | kubectl delete -f /kubernetes/workload.yaml --force --ignore-not-found 84 | set -e 85 | kind load docker-image -n sshpipertest sshpiper-test-image 86 | kubectl wait --for=delete pod --all --timeout=2m # ensure no leftover 87 | kubectl apply -f /kubernetes/crd.yaml 88 | kubectl apply -f /kubernetes/workload.yaml 89 | kubectl wait deployment --all --for condition=Available=True 90 | kubectl port-forward service/sshpiper --pod-running-timeout=2m --address 0.0.0.0 2222:2222 & 91 | kubectl logs -f deployment/sshpiper-deployment 92 | privileged: true 93 | depends_on: 94 | - host-publickey 95 | - host-password 96 | - piper-imageonly 97 | 98 | testrunner: 99 | environment: 100 | - SSHPIPERD_LOG_LEVEL=trace 101 | - SSHPIPERD_E2E_TEST=1 102 | - SSHPIPERD_DEBUG=${SSHPIPERD_DEBUG} 103 | - SSHPIPERD_ALLOWED_PROXY_ADDRESSES=0.0.0.0/0 104 | - SSHPIPERD_SERVER_KEY_GENERATE_MODE=notexist 105 | build: 106 | context: ../ 107 | target: testrunner 108 | args: 109 | - BUILDTAGS=e2e 110 | volumes: 111 | - ..:/src 112 | - shared:/shared 113 | - publickey_authorized_keys:/publickey_authorized_keys 114 | - /var/run/docker.sock:/var/run/docker.sock 115 | - kubeconfig:/root/.kube:ro 116 | command: ["./e2eentry.sh"] 117 | privileged: true 118 | working_dir: /src/e2e 119 | depends_on: 120 | - host-publickey 121 | - host-password 122 | - host-capublickey 123 | - host-k8s-proxy 124 | 125 | # ensure sshpiperd image works 126 | piper-imageonly: 127 | environment: 128 | - SSHPIPERD_LOG_LEVEL=trace 129 | build: ../ 130 | image: sshpiper-test-image 131 | 132 | volumes: 133 | shared: 134 | driver_opts: 135 | type: tmpfs 136 | device: tmpfs 137 | 138 | publickey_authorized_keys: 139 | 140 | kubeconfig: 141 | 142 | networks: 143 | netdistract: 144 | -------------------------------------------------------------------------------- /e2e/docker_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | func TestDocker(t *testing.T) { 14 | piperaddr, piperport := nextAvailablePiperAddress() 15 | 16 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 17 | "-p", 18 | piperport, 19 | "/sshpiperd/plugins/docker", 20 | ) 21 | 22 | if err != nil { 23 | t.Errorf("failed to run sshpiperd: %v", err) 24 | } 25 | 26 | defer killCmd(piper) 27 | 28 | waitForEndpointReady(piperaddr) 29 | 30 | t.Run("password", func(t *testing.T) { 31 | randtext := uuid.New().String() 32 | targetfie := uuid.New().String() 33 | 34 | c, stdin, stdout, err := runCmd( 35 | "ssh", 36 | "-v", 37 | "-o", 38 | "StrictHostKeyChecking=no", 39 | "-o", 40 | "UserKnownHostsFile=/dev/null", 41 | "-p", 42 | piperport, 43 | "-l", 44 | "pass", 45 | "127.0.0.1", 46 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 47 | ) 48 | 49 | if err != nil { 50 | t.Errorf("failed to ssh to piper-fixed, %v", err) 51 | } 52 | 53 | defer killCmd(c) 54 | 55 | enterPassword(stdin, stdout, "pass") 56 | 57 | time.Sleep(time.Second) // wait for file flush 58 | 59 | checkSharedFileContent(t, targetfie, randtext) 60 | }) 61 | 62 | t.Run("key", func(t *testing.T) { 63 | 64 | keyfiledir, err := os.MkdirTemp("", "") 65 | if err != nil { 66 | t.Errorf("failed to create temp key file: %v", err) 67 | } 68 | 69 | keyfile := path.Join(keyfiledir, "key") 70 | 71 | if err := os.WriteFile(keyfile, []byte(testprivatekey), 0400); err != nil { 72 | t.Errorf("failed to write to test key: %v", err) 73 | } 74 | 75 | if err := os.WriteFile("/publickey_authorized_keys/authorized_keys", []byte(`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRGTH325rDUp12tplwukHmR8ytbC9TPZ886gCstynP1`), 0400); err != nil { 76 | t.Errorf("failed to write to authorized_keys: %v", err) 77 | } 78 | 79 | randtext := uuid.New().String() 80 | targetfie := uuid.New().String() 81 | 82 | c, _, _, err := runCmd( 83 | "ssh", 84 | "-v", 85 | "-o", 86 | "StrictHostKeyChecking=no", 87 | "-o", 88 | "UserKnownHostsFile=/dev/null", 89 | "-p", 90 | piperport, 91 | "-l", 92 | "anyuser", 93 | "-i", 94 | keyfile, 95 | "127.0.0.1", 96 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 97 | ) 98 | 99 | if err != nil { 100 | t.Errorf("failed to ssh to piper-fixed, %v", err) 101 | } 102 | 103 | defer killCmd(c) 104 | 105 | time.Sleep(time.Second) // wait for file flush 106 | 107 | checkSharedFileContent(t, targetfie, randtext) 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /e2e/e2eentry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | groupadd -f testgroup && \ 5 | useradd -m -G testgroup testgroupuser 6 | 7 | if [ "${SSHPIPERD_DEBUG}" == "1" ]; then 8 | echo "enter debug on hold mode" 9 | echo "run [docker exec -ti e2e_testrunner_1 bash] to run to attach" 10 | sleep infinity; 11 | else 12 | go test -v; 13 | fi 14 | -------------------------------------------------------------------------------- /e2e/fixed_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | func TestOldSshd(t *testing.T) { 14 | 15 | piperaddr, piperport := nextAvailablePiperAddress() 16 | 17 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 18 | "-p", 19 | piperport, 20 | "/sshpiperd/plugins/fixed", 21 | "--target", 22 | "host-password-old:2222", 23 | ) 24 | 25 | if err != nil { 26 | t.Errorf("failed to run sshpiperd: %v", err) 27 | } 28 | 29 | defer killCmd(piper) 30 | 31 | waitForEndpointReady(piperaddr) 32 | 33 | for _, tc := range []struct { 34 | name string 35 | bin string 36 | }{ 37 | { 38 | name: "without-sshping", 39 | bin: "ssh-8.0p1", 40 | }, 41 | { 42 | name: "with-sshping", 43 | bin: "ssh-9.8p1", 44 | }, 45 | } { 46 | t.Run(tc.name, func(t *testing.T) { 47 | randtext := uuid.New().String() 48 | targetfie := uuid.New().String() 49 | 50 | c, stdin, stdout, err := runCmd( 51 | tc.bin, 52 | "-v", 53 | "-o", 54 | "StrictHostKeyChecking=no", 55 | "-o", 56 | "UserKnownHostsFile=/dev/null", 57 | "-o", 58 | "RequestTTY=yes", 59 | "-p", 60 | piperport, 61 | "-l", 62 | "user", 63 | "127.0.0.1", 64 | fmt.Sprintf(`sh -c "echo SSHREADY && sleep 1 && echo -n %v > /shared/%v"`, randtext, targetfie), // sleep 5 to cover https://github.com/tg123/sshpiper/issues/323 65 | ) 66 | 67 | if err != nil { 68 | t.Errorf("failed to ssh to piper-fixed, %v", err) 69 | } 70 | 71 | defer killCmd(c) 72 | 73 | enterPassword(stdin, stdout, "pass") 74 | 75 | waitForStdoutContains(stdout, "SSHREADY", func(_ string) { 76 | _, _ = fmt.Fprintf(stdin, "%v\n", "triggerping") 77 | }) 78 | 79 | time.Sleep(time.Second * 3) // wait for file flush 80 | 81 | checkSharedFileContent(t, targetfie, randtext) 82 | }) 83 | } 84 | 85 | } 86 | 87 | func TestHostkeyParam(t *testing.T) { 88 | piperaddr, piperport := nextAvailablePiperAddress() 89 | keyparam := base64.StdEncoding.EncodeToString([]byte(testprivatekey)) 90 | 91 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 92 | "-p", 93 | piperport, 94 | "--server-key-data", 95 | keyparam, 96 | "/sshpiperd/plugins/fixed", 97 | "--target", 98 | "host-password:2222", 99 | ) 100 | 101 | if err != nil { 102 | t.Errorf("failed to run sshpiperd: %v", err) 103 | } 104 | 105 | defer killCmd(piper) 106 | 107 | waitForEndpointReady(piperaddr) 108 | 109 | b, err := runAndGetStdout( 110 | "ssh-keyscan", 111 | "-p", 112 | piperport, 113 | "127.0.0.1", 114 | ) 115 | 116 | if !strings.Contains(string(b), testpublickey) { 117 | t.Errorf("failed to get correct hostkey, %v", err) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /e2e/grpcplugin_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/base64" 9 | "encoding/pem" 10 | "fmt" 11 | "net" 12 | "net/http" 13 | "net/rpc" 14 | "testing" 15 | "time" 16 | 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | func createFakeSshServer(config *ssh.ServerConfig) net.Listener { 21 | config.SetDefaults() 22 | private, _ := ssh.ParsePrivateKey([]byte(testprivatekey)) 23 | config.AddHostKey(private) 24 | 25 | l, err := net.Listen("tcp", "0.0.0.0:0") 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | go func() { 31 | for { 32 | l, err := l.Accept() 33 | if err != nil { 34 | break 35 | } 36 | 37 | go func() { 38 | _, _, reqs, err := ssh.NewServerConn(l, config) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | go ssh.DiscardRequests(reqs) 44 | }() 45 | } 46 | }() 47 | 48 | return l 49 | } 50 | 51 | type rpcServer struct { 52 | NewConnectionCallback func() error 53 | PasswordCallback func(string) (string, error) 54 | PipeStartCallback func() error 55 | PipeErrorCallback func(string) error 56 | } 57 | 58 | func (r *rpcServer) NewConnection(args string, reply *string) error { 59 | *reply = "" 60 | 61 | if r.NewConnectionCallback != nil { 62 | return r.NewConnectionCallback() 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (r *rpcServer) PipeStart(args string, reply *string) error { 69 | *reply = "" 70 | 71 | if r.PipeStartCallback != nil { 72 | return r.PipeStartCallback() 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (r *rpcServer) PipeError(args string, reply *string) error { 79 | *reply = "" 80 | 81 | if r.PipeErrorCallback != nil { 82 | return r.PipeErrorCallback(args) 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (r *rpcServer) Password(args string, reply *string) error { 89 | if r.PasswordCallback != nil { 90 | rpl, err := r.PasswordCallback(args) 91 | if err != nil { 92 | return err 93 | } 94 | *reply = rpl 95 | return nil 96 | } 97 | 98 | *reply = "" 99 | return nil 100 | } 101 | 102 | func createRpcServer(r *rpcServer) net.Listener { 103 | l, err := net.Listen("tcp", "0.0.0.0:0") 104 | if err != nil { 105 | panic(err) 106 | } 107 | 108 | _ = rpc.RegisterName("TestPlugin", r) 109 | rpc.HandleHTTP() 110 | go func() { 111 | _ = http.Serve(l, nil) 112 | }() 113 | 114 | return l 115 | } 116 | 117 | func TestGrpcPlugin(t *testing.T) { 118 | 119 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 120 | if err != nil { 121 | t.Fatalf("failed to generate private key: %v", err) 122 | } 123 | 124 | privKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) 125 | privKeyPem := pem.EncodeToMemory( 126 | &pem.Block{ 127 | Type: "RSA PRIVATE KEY", 128 | Bytes: privKeyBytes, 129 | }, 130 | ) 131 | 132 | sshkey, err := ssh.NewSignerFromKey(privateKey) 133 | if err != nil { 134 | t.Fatalf("failed to create ssh signer: %v", err) 135 | } 136 | 137 | sshsvr := createFakeSshServer(&ssh.ServerConfig{ 138 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 139 | if !bytes.Equal(key.Marshal(), sshkey.PublicKey().Marshal()) { 140 | return nil, fmt.Errorf("public key mismatch") 141 | } 142 | 143 | return nil, nil 144 | }, 145 | }) 146 | defer sshsvr.Close() 147 | 148 | cbtriggered := make(map[string]bool) 149 | 150 | rpcsvr := createRpcServer(&rpcServer{ 151 | NewConnectionCallback: func() error { 152 | cbtriggered["NewConnection"] = true 153 | return nil 154 | }, 155 | PasswordCallback: func(pass string) (string, error) { 156 | cbtriggered["Password"] = true 157 | return "rpcpassword", nil 158 | }, 159 | PipeStartCallback: func() error { 160 | cbtriggered["PipeStart"] = true 161 | return nil 162 | }, 163 | PipeErrorCallback: func(err string) error { 164 | cbtriggered["PipeError"] = true 165 | return nil 166 | }, 167 | }) 168 | defer rpcsvr.Close() 169 | 170 | piperaddr, piperport := nextAvailablePiperAddress() 171 | 172 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 173 | "-p", 174 | piperport, 175 | "/sshpiperd/plugins/testgrpcplugin", 176 | "--testsshserver", 177 | sshsvr.Addr().String(), 178 | "--rpcserver", 179 | rpcsvr.Addr().String(), 180 | "--testremotekey", 181 | base64.StdEncoding.EncodeToString(privKeyPem), 182 | ) 183 | 184 | if err != nil { 185 | t.Errorf("failed to run sshpiperd: %v", err) 186 | } 187 | 188 | defer killCmd(piper) 189 | 190 | waitForEndpointReady(piperaddr) 191 | 192 | client, err := ssh.Dial("tcp", piperaddr, &ssh.ClientConfig{ 193 | User: "username", 194 | Auth: []ssh.AuthMethod{ 195 | ssh.Password("yourpassword"), 196 | }, 197 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 198 | }) 199 | 200 | if err != nil { 201 | t.Fatalf("failed to connect to sshpiperd: %v", err) 202 | } 203 | 204 | client.Close() 205 | 206 | time.Sleep(1 * time.Second) // wait for callbacks to be triggered 207 | 208 | if !cbtriggered["NewConnection"] { 209 | t.Errorf("NewConnection callback not triggered") 210 | } 211 | 212 | if !cbtriggered["Password"] { 213 | t.Errorf("Password callback not triggered") 214 | } 215 | 216 | if !cbtriggered["PipeStart"] { 217 | t.Errorf("PipeStart callback not triggered") 218 | } 219 | 220 | if !cbtriggered["PipeError"] { 221 | t.Errorf("PipeError callback not triggered") 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /e2e/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | func TestKubernetes(t *testing.T) { 14 | piperhost := "host-k8s-proxy" 15 | piperport := "2222" 16 | piperaddr := piperhost + ":" + piperport 17 | waitForEndpointReadyWithTimeout(piperaddr, time.Minute*5) 18 | 19 | pubkeycases := []struct { 20 | title string 21 | user string 22 | }{ 23 | { 24 | title: "key_pubkey_cacthall", 25 | user: "anyuser", 26 | }, 27 | { 28 | title: "key_custom_field", 29 | user: "custom_field", 30 | }, 31 | { 32 | title: "key_authorizedfile", 33 | user: "authorizedfile", 34 | }, 35 | { 36 | title: "key_public_ca", 37 | user: "hostcapublickey", 38 | }, 39 | { 40 | title: "key_to_pass", 41 | user: "keytopass", 42 | }, 43 | } 44 | 45 | for _, testcase := range pubkeycases { 46 | t.Run(testcase.title, func(t *testing.T) { 47 | 48 | keyfiledir, err := os.MkdirTemp("", "") 49 | if err != nil { 50 | t.Errorf("failed to create temp key file: %v", err) 51 | } 52 | 53 | keyfile := path.Join(keyfiledir, "key") 54 | 55 | if err := os.WriteFile(keyfile, []byte(testprivatekey), 0400); err != nil { 56 | t.Errorf("failed to write to test key: %v", err) 57 | } 58 | 59 | if err := os.WriteFile("/publickey_authorized_keys/authorized_keys", []byte(`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRGTH325rDUp12tplwukHmR8ytbC9TPZ886gCstynP1`), 0400); err != nil { 60 | t.Errorf("failed to write to authorized_keys: %v", err) 61 | } 62 | 63 | randtext := uuid.New().String() 64 | targetfie := uuid.New().String() 65 | 66 | c, _, _, err := runCmd( 67 | "ssh", 68 | "-v", 69 | "-o", 70 | "StrictHostKeyChecking=no", 71 | "-o", 72 | "UserKnownHostsFile=/dev/null", 73 | "-p", 74 | piperport, 75 | "-l", 76 | testcase.user, 77 | "-i", 78 | keyfile, 79 | piperhost, 80 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 81 | ) 82 | 83 | if err != nil { 84 | t.Errorf("failed to ssh to piper-fixed, %v", err) 85 | } 86 | 87 | defer killCmd(c) 88 | 89 | time.Sleep(time.Second) // wait for file flush 90 | 91 | checkSharedFileContent(t, targetfie, randtext) 92 | }) 93 | } 94 | 95 | passwordcases := []struct { 96 | title string 97 | user string 98 | password string 99 | }{ 100 | { 101 | title: "password", 102 | user: "pass", 103 | password: "pass", 104 | }, 105 | { 106 | title: "password_htpwd", 107 | user: "htpwd", 108 | password: "htpassword", 109 | }, 110 | { 111 | title: "password_htpasswd_file", 112 | user: "htpwdfile", 113 | password: "htpasswordfile", 114 | }, 115 | } 116 | 117 | for _, testcase := range passwordcases { 118 | 119 | t.Run(testcase.title, func(t *testing.T) { 120 | randtext := uuid.New().String() 121 | targetfie := uuid.New().String() 122 | 123 | c, stdin, stdout, err := runCmd( 124 | "ssh", 125 | "-v", 126 | "-o", 127 | "StrictHostKeyChecking=no", 128 | "-o", 129 | "UserKnownHostsFile=/dev/null", 130 | "-p", 131 | piperport, 132 | "-l", 133 | testcase.user, 134 | piperhost, 135 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 136 | ) 137 | 138 | if err != nil { 139 | t.Errorf("failed to ssh to piper-fixed, %v", err) 140 | } 141 | 142 | defer killCmd(c) 143 | 144 | enterPassword(stdin, stdout, testcase.password) 145 | 146 | time.Sleep(time.Second) // wait for file flush 147 | 148 | checkSharedFileContent(t, targetfie, randtext) 149 | }) 150 | 151 | } 152 | 153 | { 154 | // fallback to password 155 | t.Run("fallback to password", func(t *testing.T) { 156 | randtext := uuid.New().String() 157 | targetfie := uuid.New().String() 158 | 159 | keyfiledir, err := os.MkdirTemp("", "") 160 | if err != nil { 161 | t.Errorf("failed to create temp key file: %v", err) 162 | } 163 | 164 | keyfile := path.Join(keyfiledir, "key") 165 | 166 | if err := runCmdAndWait( 167 | "ssh-keygen", 168 | "-N", 169 | "", 170 | "-f", 171 | keyfile, 172 | ); err != nil { 173 | t.Errorf("failed to generate key: %v", err) 174 | } 175 | 176 | c, stdin, stdout, err := runCmd( 177 | "ssh", 178 | "-v", 179 | "-o", 180 | "StrictHostKeyChecking=no", 181 | "-o", 182 | "UserKnownHostsFile=/dev/null", 183 | "-p", 184 | piperport, 185 | "-l", 186 | "pass", 187 | "-i", 188 | keyfile, 189 | piperhost, 190 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 191 | ) 192 | 193 | if err != nil { 194 | t.Errorf("failed to ssh to piper-fixed, %v", err) 195 | } 196 | 197 | defer killCmd(c) 198 | 199 | enterPassword(stdin, stdout, "pass") 200 | 201 | time.Sleep(time.Second) // wait for file flush 202 | 203 | checkSharedFileContent(t, targetfie, randtext) 204 | }) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /e2e/kubetools/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | apt-transport-https \ 5 | ca-certificates \ 6 | curl \ 7 | gnupg \ 8 | lsb-release 9 | 10 | RUN curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 && \ 11 | chmod +x ./kind && \ 12 | mv ./kind /bin/kind 13 | 14 | RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ 15 | chmod +x ./kubectl && \ 16 | mv ./kubectl /bin/kubectl 17 | 18 | RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - 19 | 20 | RUN echo "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/ubuntu \ 21 | $(lsb_release -cs) \ 22 | stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 23 | 24 | RUN apt-get update && apt-get install -y docker-ce-cli -------------------------------------------------------------------------------- /e2e/main_test.go: -------------------------------------------------------------------------------- 1 | // run with docker-compose up --build --abort-on-container-exit 2 | 3 | package e2e_test 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net" 12 | "os" 13 | "os/exec" 14 | "strconv" 15 | "strings" 16 | "syscall" 17 | "testing" 18 | "time" 19 | 20 | "github.com/creack/pty" 21 | ) 22 | 23 | const testprivatekey = `-----BEGIN OPENSSH PRIVATE KEY----- 24 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 25 | QyNTUxOQAAACDURkx99uaw1KddraZcLpB5kfMrWwvUz2fPOoArLcpz9QAAAJC+j0+Svo9P 26 | kgAAAAtzc2gtZWQyNTUxOQAAACDURkx99uaw1KddraZcLpB5kfMrWwvUz2fPOoArLcpz9Q 27 | AAAEDcQgdh2z2r/6blq0ziJ1l6s6IAX8C+9QHfAH931cHNO9RGTH325rDUp12tplwukHmR 28 | 8ytbC9TPZ886gCstynP1AAAADWJvbGlhbkB1YnVudHU= 29 | -----END OPENSSH PRIVATE KEY----- 30 | ` 31 | 32 | const testpublickey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRGTH325rDUp12tplwukHmR8ytbC9TPZ886gCstynP1` 33 | 34 | const waitTimeout = time.Second * 10 35 | 36 | func waitForEndpointReady(addr string) { 37 | waitForEndpointReadyWithTimeout(addr, waitTimeout) 38 | } 39 | 40 | func waitForEndpointReadyWithTimeout(addr string, timeout time.Duration) { 41 | now := time.Now() 42 | timeout = max(timeout, waitTimeout) 43 | for { 44 | if time.Since(now) > timeout { 45 | log.Panic("timeout waiting for endpoint " + addr) 46 | } 47 | 48 | conn, err := net.Dial("tcp", addr) 49 | if err == nil { 50 | log.Printf("endpoint %s is ready", addr) 51 | conn.Close() 52 | break 53 | } 54 | time.Sleep(time.Second) 55 | } 56 | } 57 | 58 | func runCmd(cmd string, args ...string) (*exec.Cmd, io.Writer, io.Reader, error) { 59 | newargs := append([]string{cmd}, args...) 60 | newargs = append([]string{"-i0", "-o0", "-e0"}, newargs...) 61 | c := exec.Command("stdbuf", newargs...) 62 | c.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} 63 | f, err := pty.Start(c) 64 | if err != nil { 65 | return nil, nil, nil, err 66 | } 67 | 68 | var buf bytes.Buffer 69 | r := io.TeeReader(f, &buf) 70 | go func() { 71 | _, _ = io.Copy(os.Stdout, r) 72 | }() 73 | 74 | log.Printf("starting %v", c.Args) 75 | 76 | return c, f, &buf, nil 77 | } 78 | 79 | func runCmdAndWait(cmd string, args ...string) error { 80 | c, _, _, err := runCmd(cmd, args...) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return c.Wait() 86 | } 87 | 88 | func waitForStdoutContains(stdout io.Reader, text string, cb func(string)) { 89 | st := time.Now() 90 | for { 91 | scanner := bufio.NewScanner(stdout) 92 | for scanner.Scan() { 93 | line := scanner.Text() 94 | if strings.Contains(line, text) { 95 | cb(line) 96 | return 97 | } 98 | } 99 | 100 | if time.Since(st) > waitTimeout { 101 | log.Panicf("timeout waiting for [%s] from prompt", text) 102 | return 103 | } 104 | 105 | time.Sleep(time.Second) // stdout has no data yet 106 | } 107 | } 108 | 109 | func enterPassword(stdin io.Writer, stdout io.Reader, password string) { 110 | waitForStdoutContains(stdout, "'s password", func(_ string) { 111 | _, _ = fmt.Fprintf(stdin, "%v\n", password) 112 | log.Printf("got password prompt, sending password") 113 | }) 114 | } 115 | 116 | func checkSharedFileContent(t *testing.T, targetfie string, expected string) { 117 | f, err := os.Open(fmt.Sprintf("/shared/%v", targetfie)) 118 | if err != nil { 119 | t.Errorf("failed to open shared file, %v", err) 120 | } 121 | defer f.Close() 122 | 123 | b, err := io.ReadAll(f) 124 | if err != nil { 125 | t.Errorf("failed to read shared file, %v", err) 126 | } 127 | 128 | if string(b) != expected { 129 | t.Errorf("shared file content mismatch, expected %v, got %v", expected, string(b)) 130 | } 131 | } 132 | 133 | func killCmd(c *exec.Cmd) { 134 | if c.Process != nil { 135 | if err := c.Process.Kill(); err != nil { 136 | log.Printf("failed to kill ssh process, %v", err) 137 | } 138 | } 139 | } 140 | 141 | func runAndGetStdout(cmd string, args ...string) ([]byte, error) { 142 | c, _, stdout, err := runCmd(cmd, args...) 143 | 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | if err := c.Wait(); err != nil { 149 | return nil, err 150 | } 151 | 152 | return io.ReadAll(stdout) 153 | } 154 | 155 | func nextAvaliablePort() int { 156 | l, err := net.Listen("tcp", ":0") 157 | if err != nil { 158 | log.Panic(err) 159 | } 160 | defer l.Close() 161 | return l.Addr().(*net.TCPAddr).Port 162 | } 163 | 164 | func nextAvailablePiperAddress() (string, string) { 165 | port := strconv.Itoa(nextAvaliablePort()) 166 | return net.JoinHostPort("127.0.0.1", (port)), port 167 | } 168 | 169 | func TestMain(m *testing.M) { 170 | 171 | if os.Getenv("SSHPIPERD_E2E_TEST") != "1" { 172 | log.Printf("skipping e2e test") 173 | os.Exit(0) 174 | return 175 | } 176 | 177 | _ = runCmdAndWait("ssh", "-V") 178 | 179 | for _, ep := range []string{ 180 | "host-password:2222", 181 | "host-publickey:2222", 182 | } { 183 | waitForEndpointReady(ep) 184 | } 185 | 186 | os.Exit(m.Run()) 187 | } 188 | -------------------------------------------------------------------------------- /e2e/sshdconfig/banner: -------------------------------------------------------------------------------- 1 | sshpiper banner from upstream test 2 | -------------------------------------------------------------------------------- /e2e/sshdconfig/banner.conf: -------------------------------------------------------------------------------- 1 | Banner /tmp/banner -------------------------------------------------------------------------------- /e2e/sshdconfig/no_penalties.conf: -------------------------------------------------------------------------------- 1 | PerSourcePenaltyExemptList 0.0.0.0/0 -------------------------------------------------------------------------------- /e2e/sshdconfig/trusted-ca.conf: -------------------------------------------------------------------------------- 1 | TrustedUserCAKeys /config/sshd/trusted-ca.pub 2 | -------------------------------------------------------------------------------- /e2e/sshdconfig/trusted-ca.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACCa8wnkN2vpvD5wPylJiviQHwtbTO0htoJBpltvZ149ZwAAAJCXKgKTlyoC 4 | kwAAAAtzc2gtZWQyNTUxOQAAACCa8wnkN2vpvD5wPylJiviQHwtbTO0htoJBpltvZ149Zw 5 | AAAEDt/vP5TaisrihzBV6UwHTFH4PwXtRz6MpWrbjmCciBgprzCeQ3a+m8PnA/KUmK+JAf 6 | C1tM7SG2gkGmW29nXj1nAAAAC3BnaWJzb25Ad3NsAQI= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /e2e/sshdconfig/trusted-ca.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJrzCeQ3a+m8PnA/KUmK+JAfC1tM7SG2gkGmW29nXj1n 2 | -------------------------------------------------------------------------------- /e2e/testplugin/testgetmetaplugin/main.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package main 4 | 5 | import ( 6 | log "github.com/sirupsen/logrus" 7 | "github.com/tg123/sshpiper/libplugin" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func main() { 12 | 13 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 14 | Name: "getmeta", 15 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 16 | 17 | return &libplugin.SshPiperPluginConfig{ 18 | PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { 19 | 20 | target := conn.GetMeta("targetaddr") 21 | 22 | host, port, err := libplugin.SplitHostPortForSSH(target) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | log.Info("routing to ", target) 28 | return &libplugin.Upstream{ 29 | Host: host, 30 | Port: int32(port), 31 | IgnoreHostKey: true, 32 | Auth: libplugin.CreatePasswordAuth(password), 33 | }, nil 34 | }, 35 | }, nil 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /e2e/testplugin/testgrpcplugin/main.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package main 4 | 5 | import ( 6 | "crypto" 7 | "encoding/base64" 8 | "fmt" 9 | "net/rpc" 10 | 11 | "github.com/tg123/sshpiper/libplugin" 12 | "github.com/urfave/cli/v2" 13 | "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | func main() { 17 | 18 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 19 | Name: "testplugin", 20 | Usage: "e2e test plugin only", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "rpcserver", 24 | Required: true, 25 | }, 26 | &cli.StringFlag{ 27 | Name: "testsshserver", 28 | Required: true, 29 | }, 30 | &cli.StringFlag{ 31 | Name: "testremotekey", 32 | Required: true, 33 | }, 34 | }, 35 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 36 | 37 | rpcclient, err := rpc.DialHTTP("tcp", c.String("rpcserver")) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | host, port, err := libplugin.SplitHostPortForSSH(c.String("testsshserver")) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | keydata, err := base64.StdEncoding.DecodeString(c.String("testremotekey")) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | key, err := ssh.ParseRawPrivateKey(keydata) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | _, ok := key.(crypto.Signer) 58 | if !ok { 59 | return nil, fmt.Errorf("key format not supported") 60 | } 61 | 62 | return &libplugin.SshPiperPluginConfig{ 63 | NewConnectionCallback: func(conn libplugin.ConnMetadata) error { 64 | return rpcclient.Call("TestPlugin.NewConnection", "", nil) 65 | }, 66 | PipeStartCallback: func(conn libplugin.ConnMetadata) { 67 | rpcclient.Call("TestPlugin.PipeStart", "", nil) 68 | }, 69 | PipeErrorCallback: func(conn libplugin.ConnMetadata, err error) { 70 | rpcclient.Call("TestPlugin.PipeError", err.Error(), nil) 71 | }, 72 | PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { 73 | var newpass string 74 | err := rpcclient.Call("TestPlugin.Password", string(password), &newpass) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return &libplugin.Upstream{ 80 | Host: host, 81 | Port: int32(port), 82 | Auth: libplugin.CreateRemoteSignerAuth("testplugin"), 83 | IgnoreHostKey: true, 84 | }, nil 85 | }, 86 | GrpcRemoteSignerFactory: func(metadata string) (crypto.Signer, error) { 87 | if metadata != "testplugin" { 88 | return nil, fmt.Errorf("metadata mismatch") 89 | } 90 | 91 | return key.(crypto.Signer), nil 92 | }, 93 | }, nil 94 | }, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /e2e/testplugin/testsetmetaplugin/main.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/tg123/sshpiper/libplugin" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func main() { 11 | 12 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 13 | Name: "setmeta", 14 | Flags: []cli.Flag{ 15 | &cli.StringFlag{ 16 | Name: "targetaddr", 17 | Required: true, 18 | }, 19 | }, 20 | CreateConfig: func(ctx *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 21 | return &libplugin.SshPiperPluginConfig{ 22 | 23 | NoClientAuthCallback: func(conn libplugin.ConnMetadata) (*libplugin.Upstream, error) { 24 | 25 | return &libplugin.Upstream{ 26 | Auth: libplugin.CreateNextPluginAuth(map[string]string{ 27 | "targetaddr": ctx.String("targetaddr"), 28 | }), 29 | }, nil 30 | }, 31 | }, nil 32 | }, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /e2e/workingdir_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | const workingdir = "/shared/workingdir" 15 | 16 | func ensureWorkingDirectory() { 17 | err := os.MkdirAll(workingdir, 0700) 18 | if err != nil { 19 | log.Panicf("failed to create working directory %s: %v", workingdir, err) 20 | } 21 | } 22 | 23 | func TestWorkingDirectory(t *testing.T) { 24 | 25 | piperaddr, piperport := nextAvailablePiperAddress() 26 | 27 | piper, _, _, err := runCmd("/sshpiperd/sshpiperd", 28 | "-p", 29 | piperport, 30 | "/sshpiperd/plugins/workingdir", 31 | "--root", 32 | workingdir, 33 | ) 34 | 35 | if err != nil { 36 | t.Errorf("failed to run sshpiperd: %v", err) 37 | } 38 | 39 | defer killCmd(piper) 40 | 41 | waitForEndpointReady(piperaddr) 42 | 43 | ensureWorkingDirectory() 44 | 45 | t.Run("bypassword", func(t *testing.T) { 46 | userdir := path.Join(workingdir, "bypassword") 47 | 48 | { 49 | if err := os.MkdirAll(userdir, 0700); err != nil { 50 | t.Errorf("failed to create working directory %s: %v", userdir, err) 51 | } 52 | 53 | if err := os.WriteFile(path.Join(userdir, "sshpiper_upstream"), []byte("user@host-password:2222"), 0400); err != nil { 54 | t.Errorf("failed to write upstream file: %v", err) 55 | } 56 | } 57 | 58 | { 59 | b, err := runAndGetStdout( 60 | "ssh-keyscan", 61 | "-p", 62 | "2222", 63 | "host-password", 64 | ) 65 | 66 | if err != nil { 67 | t.Errorf("failed to run ssh-keyscan: %v", err) 68 | } 69 | 70 | if err := os.WriteFile(path.Join(userdir, "known_hosts"), b, 0400); err != nil { 71 | t.Errorf("failed to write known_hosts: %v", err) 72 | } 73 | } 74 | 75 | { 76 | randtext := uuid.New().String() 77 | targetfie := uuid.New().String() 78 | 79 | c, stdin, stdout, err := runCmd( 80 | "ssh", 81 | "-v", 82 | "-o", 83 | "StrictHostKeyChecking=no", 84 | "-o", 85 | "UserKnownHostsFile=/dev/null", 86 | "-p", 87 | piperport, 88 | "-l", 89 | "bypassword", 90 | "127.0.0.1", 91 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 92 | ) 93 | 94 | if err != nil { 95 | t.Errorf("failed to ssh to piper-workingdir, %v", err) 96 | } 97 | 98 | defer killCmd(c) 99 | 100 | enterPassword(stdin, stdout, "pass") 101 | 102 | time.Sleep(time.Second) // wait for file flush 103 | 104 | checkSharedFileContent(t, targetfie, randtext) 105 | } 106 | }) 107 | 108 | t.Run("bypublickey", func(t *testing.T) { 109 | userdir := path.Join(workingdir, "bypublickey") 110 | if err := os.MkdirAll(userdir, 0700); err != nil { 111 | t.Errorf("failed to create working directory %s: %v", userdir, err) 112 | } 113 | 114 | if err := os.WriteFile(path.Join(userdir, "sshpiper_upstream"), []byte("user@host-publickey:2222"), 0400); err != nil { 115 | t.Errorf("failed to write upstream file: %v", err) 116 | } 117 | 118 | { 119 | b, err := runAndGetStdout( 120 | "ssh-keyscan", 121 | "-p", 122 | "2222", 123 | "host-publickey", 124 | ) 125 | 126 | if err != nil { 127 | t.Errorf("failed to run ssh-keyscan: %v", err) 128 | } 129 | 130 | if err := os.WriteFile(path.Join(userdir, "known_hosts"), b, 0400); err != nil { 131 | t.Errorf("failed to write known_hosts: %v", err) 132 | } 133 | } 134 | 135 | keydir, err := os.MkdirTemp("", "") 136 | // generate a local key 137 | if err != nil { 138 | t.Errorf("failed to create temp dir: %v", err) 139 | } 140 | 141 | { 142 | 143 | if err := runCmdAndWait("rm", "-f", path.Join(keydir, "id_rsa")); err != nil { 144 | t.Errorf("failed to remove id_rsa: %v", err) 145 | } 146 | 147 | if err := runCmdAndWait( 148 | "ssh-keygen", 149 | "-N", 150 | "", 151 | "-f", 152 | path.Join(keydir, "id_rsa"), 153 | ); err != nil { 154 | t.Errorf("failed to generate private key: %v", err) 155 | } 156 | 157 | if err := runCmdAndWait( 158 | "/bin/cp", 159 | path.Join(keydir, "id_rsa.pub"), 160 | path.Join(userdir, "authorized_keys"), 161 | ); err != nil { 162 | t.Errorf("failed to copy public key: %v", err) 163 | } 164 | 165 | if err := runCmdAndWait( 166 | "chmod", 167 | "0400", 168 | path.Join(userdir, "authorized_keys"), 169 | ); err != nil { 170 | t.Errorf("failed to chmod public key: %v", err) 171 | } 172 | 173 | // set upstream key 174 | if err := runCmdAndWait("rm", "-f", path.Join(userdir, "id_rsa")); err != nil { 175 | t.Errorf("failed to remove id_rsa: %v", err) 176 | } 177 | 178 | if err := runCmdAndWait( 179 | "ssh-keygen", 180 | "-N", 181 | "", 182 | "-f", 183 | path.Join(userdir, "id_rsa"), 184 | ); err != nil { 185 | t.Errorf("failed to generate private key: %v", err) 186 | } 187 | 188 | if err := runCmdAndWait( 189 | "/bin/cp", 190 | path.Join(userdir, "id_rsa.pub"), 191 | "/publickey_authorized_keys/authorized_keys", 192 | ); err != nil { 193 | t.Errorf("failed to copy public key: %v", err) 194 | } 195 | } 196 | 197 | { 198 | randtext := uuid.New().String() 199 | targetfie := uuid.New().String() 200 | 201 | c, _, _, err := runCmd( 202 | "ssh", 203 | "-v", 204 | "-o", 205 | "StrictHostKeyChecking=no", 206 | "-o", 207 | "UserKnownHostsFile=/dev/null", 208 | "-p", 209 | piperport, 210 | "-l", 211 | "bypublickey", 212 | "-i", 213 | path.Join(keydir, "id_rsa"), 214 | "127.0.0.1", 215 | fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), 216 | ) 217 | 218 | if err != nil { 219 | t.Errorf("failed to ssh to piper-workingdir, %v", err) 220 | } 221 | 222 | defer killCmd(c) 223 | 224 | time.Sleep(time.Second) // wait for file flush 225 | 226 | checkSharedFileContent(t, targetfie, randtext) 227 | } 228 | 229 | }) 230 | } 231 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tg123/sshpiper 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.3 6 | 7 | replace golang.org/x/crypto => ./crypto 8 | 9 | require ( 10 | github.com/creack/pty v1.1.24 11 | github.com/docker/docker v28.2.2+incompatible 12 | github.com/google/uuid v1.6.0 13 | github.com/patrickmn/go-cache v2.1.0+incompatible 14 | github.com/pires/go-proxyproto v0.8.1 15 | github.com/ramr/go-reaper v0.2.3 16 | github.com/sirupsen/logrus v1.9.3 17 | github.com/tg123/go-htpasswd v1.2.4 18 | github.com/tg123/jobobject v0.1.0 19 | github.com/tg123/remotesigner v0.0.3 20 | github.com/urfave/cli/v2 v2.27.6 21 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba 22 | golang.org/x/crypto v0.38.0 23 | google.golang.org/grpc v1.72.2 24 | google.golang.org/protobuf v1.36.6 25 | gopkg.in/yaml.v3 v3.0.1 26 | k8s.io/api v0.33.1 27 | k8s.io/apimachinery v0.33.1 28 | k8s.io/client-go v0.33.1 29 | k8s.io/code-generator v0.33.1 30 | ) 31 | 32 | require ( 33 | github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect 34 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 35 | github.com/containerd/errdefs v1.0.0 // indirect 36 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 37 | github.com/containerd/log v0.1.0 // indirect 38 | github.com/distribution/reference v0.6.0 // indirect 39 | github.com/felixge/httpsnoop v1.0.4 // indirect 40 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 41 | github.com/go-logr/stdr v1.2.2 // indirect 42 | github.com/google/gnostic-models v0.6.9 // indirect 43 | github.com/moby/docker-image-spec v1.3.1 // indirect 44 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 45 | github.com/x448/float16 v0.8.4 // indirect 46 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 47 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect 48 | go.opentelemetry.io/otel v1.34.0 // indirect 49 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect 50 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 51 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 52 | golang.org/x/sync v0.14.0 // indirect 53 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 54 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 55 | k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7 // indirect 56 | sigs.k8s.io/randfill v1.0.0 // indirect 57 | ) 58 | 59 | require ( 60 | github.com/Microsoft/go-winio v0.5.2 // indirect 61 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 62 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 63 | github.com/docker/go-connections v0.4.0 // indirect 64 | github.com/docker/go-units v0.4.0 // indirect 65 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 66 | github.com/go-logr/logr v1.4.2 // indirect 67 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 68 | github.com/go-openapi/jsonreference v0.20.2 // indirect 69 | github.com/go-openapi/swag v0.23.0 // indirect 70 | github.com/gogo/protobuf v1.3.2 // indirect 71 | github.com/google/go-cmp v0.7.0 // indirect 72 | github.com/josharian/intern v1.0.0 // indirect 73 | github.com/json-iterator/go v1.1.12 // indirect 74 | github.com/mailru/easyjson v0.7.7 // indirect 75 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 76 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 77 | github.com/modern-go/reflect2 v1.0.2 // indirect 78 | github.com/morikuni/aec v1.0.0 // indirect 79 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 80 | github.com/opencontainers/go-digest v1.0.0 // indirect 81 | github.com/opencontainers/image-spec v1.0.2 // indirect 82 | github.com/pkg/errors v0.9.1 // indirect 83 | github.com/pquerna/otp v1.5.0 84 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 85 | github.com/spf13/pflag v1.0.5 // indirect 86 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 87 | golang.org/x/mod v0.21.0 // indirect 88 | golang.org/x/net v0.39.0 // indirect 89 | golang.org/x/oauth2 v0.27.0 // indirect 90 | golang.org/x/sys v0.33.0 // indirect 91 | golang.org/x/term v0.32.0 92 | golang.org/x/text v0.25.0 // indirect 93 | golang.org/x/time v0.9.0 // indirect 94 | golang.org/x/tools v0.26.0 // indirect 95 | gopkg.in/inf.v0 v0.9.1 // indirect 96 | gotest.tools/v3 v3.3.0 // indirect 97 | k8s.io/klog/v2 v2.130.1 // indirect 98 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 99 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 100 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 101 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 102 | sigs.k8s.io/yaml v1.4.0 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /libplugin/doc.go: -------------------------------------------------------------------------------- 1 | //go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative plugin.proto 2 | 3 | package libplugin 4 | -------------------------------------------------------------------------------- /libplugin/ioconn/cmd.go: -------------------------------------------------------------------------------- 1 | package ioconn 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "os/exec" 7 | ) 8 | 9 | type cmdconn struct { 10 | conn 11 | cmd *exec.Cmd 12 | } 13 | 14 | // Close closes the cmdconn and releases any associated resources. 15 | // It first closes the underlying connection and then kills the process if it is running. 16 | // If an error occurs during the closing of the connection, that error is returned. 17 | // If the process is running and cannot be killed, an error is returned. 18 | func (c *cmdconn) Close() error { 19 | err := c.conn.Close() 20 | 21 | if c.cmd.Process != nil { 22 | return c.cmd.Process.Kill() 23 | } 24 | 25 | return err 26 | } 27 | 28 | // DialCmd is a function that establishes a connection to a command's standard input, output, and error streams. 29 | // It takes a *exec.Cmd as input and returns a net.Conn, io.ReadCloser, and error. 30 | // The net.Conn represents the connection to the command's standard input and output streams. 31 | // The io.ReadCloser represents the command's standard error stream. 32 | // The error represents any error that occurred during the connection establishment. 33 | func DialCmd(cmd *exec.Cmd) (net.Conn, io.ReadCloser, error) { 34 | in, err := cmd.StdoutPipe() 35 | if err != nil { 36 | return nil, nil, err 37 | } 38 | 39 | out, err := cmd.StdinPipe() 40 | if err != nil { 41 | return nil, nil, err 42 | } 43 | 44 | stderr, err := cmd.StderrPipe() 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | 49 | if err := cmd.Start(); err != nil { 50 | return nil, nil, err 51 | } 52 | 53 | return &cmdconn{ 54 | conn: *dial(in, out), 55 | cmd: cmd, 56 | }, stderr, nil 57 | } 58 | -------------------------------------------------------------------------------- /libplugin/ioconn/cmd_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package ioconn_test 4 | 5 | import ( 6 | "os/exec" 7 | "testing" 8 | 9 | "github.com/tg123/sshpiper/libplugin/ioconn" 10 | ) 11 | 12 | func TestDialCmd(t *testing.T) { 13 | cmd := exec.Command("cat") 14 | 15 | conn, _, err := ioconn.DialCmd(cmd) 16 | if err != nil { 17 | t.Errorf("DialCmd returned an error: %v", err) 18 | } 19 | defer conn.Close() 20 | 21 | go func() { 22 | _, _ = conn.Write([]byte("world")) 23 | }() 24 | 25 | buf := make([]byte, 5) 26 | _, _ = conn.Read(buf) 27 | 28 | if string(buf) != "world" { 29 | t.Errorf("unexpected string read: %v", string(buf)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /libplugin/ioconn/conn.go: -------------------------------------------------------------------------------- 1 | package ioconn 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "time" 8 | ) 9 | 10 | type addr string 11 | 12 | func (a addr) Network() string { 13 | return "ioconn" 14 | } 15 | 16 | func (a addr) String() string { 17 | return string(a) 18 | } 19 | 20 | type conn struct { 21 | in io.ReadCloser 22 | out io.WriteCloser 23 | } 24 | 25 | // Dial creates a new network connection using the provided input and output streams. 26 | // It returns a net.Conn interface and an error, if any. 27 | // The input stream is used for reading data from the connection, 28 | // and the output stream is used for writing data to the connection. 29 | func Dial(in io.ReadCloser, out io.WriteCloser) (net.Conn, error) { 30 | if in == nil { 31 | return nil, fmt.Errorf("input stream is nil") 32 | } 33 | 34 | if out == nil { 35 | return nil, fmt.Errorf("output stream is nil") 36 | } 37 | 38 | return dial(in, out), nil 39 | } 40 | 41 | func dial(in io.ReadCloser, out io.WriteCloser) *conn { 42 | return &conn{in, out} 43 | } 44 | 45 | // Read reads data from the connection. 46 | // Read can be made to time out and return an error after a fixed 47 | // time limit; see SetDeadline and SetReadDeadline. 48 | func (c *conn) Read(b []byte) (n int, err error) { 49 | return c.in.Read(b) 50 | } 51 | 52 | // Write writes data to the connection. 53 | // Write can be made to time out and return an error after a fixed 54 | // time limit; see SetDeadline and SetWriteDeadline. 55 | func (c *conn) Write(b []byte) (n int, err error) { 56 | return c.out.Write(b) 57 | } 58 | 59 | // Close closes the connection. 60 | // Any blocked Read or Write operations will be unblocked and return errors. 61 | func (c *conn) Close() error { 62 | inerr := c.in.Close() 63 | outerr := c.out.Close() 64 | 65 | if inerr == nil { 66 | return outerr 67 | } 68 | 69 | if outerr == nil { 70 | return inerr 71 | } 72 | 73 | return fmt.Errorf("io close error in: %v, out: %v", inerr, outerr) 74 | } 75 | 76 | // LocalAddr returns the local network address, if known. 77 | func (c *conn) LocalAddr() net.Addr { 78 | return addr("ioconn:local") 79 | } 80 | 81 | // RemoteAddr returns the remote network address, if known. 82 | func (c *conn) RemoteAddr() net.Addr { 83 | return addr("ioconn:remote") 84 | } 85 | 86 | // SetDeadline sets the read and write deadlines associated 87 | // with the connection. It is equivalent to calling both 88 | // SetReadDeadline and SetWriteDeadline. 89 | // 90 | // A deadline is an absolute time after which I/O operations 91 | // fail instead of blocking. The deadline applies to all future 92 | // and pending I/O, not just the immediately following call to 93 | // Read or Write. After a deadline has been exceeded, the 94 | // connection can be refreshed by setting a deadline in the future. 95 | // 96 | // If the deadline is exceeded a call to Read or Write or to other 97 | // I/O methods will return an error that wraps os.ErrDeadlineExceeded. 98 | // This can be tested using errors.Is(err, os.ErrDeadlineExceeded). 99 | // The error's Timeout method will return true, but note that there 100 | // are other possible errors for which the Timeout method will 101 | // return true even if the deadline has not been exceeded. 102 | // 103 | // An idle timeout can be implemented by repeatedly extending 104 | // the deadline after successful Read or Write calls. 105 | // 106 | // A zero value for t means I/O operations will not time out. 107 | func (c *conn) SetDeadline(t time.Time) error { 108 | return nil 109 | } 110 | 111 | // SetReadDeadline sets the deadline for future Read calls 112 | // and any currently-blocked Read call. 113 | // A zero value for t means Read will not time out. 114 | func (c *conn) SetReadDeadline(t time.Time) error { 115 | return nil 116 | } 117 | 118 | // SetWriteDeadline sets the deadline for future Write calls 119 | // and any currently-blocked Write call. 120 | // Even if write times out, it may return n > 0, indicating that 121 | // some of the data was successfully written. 122 | // A zero value for t means Write will not time out. 123 | func (c *conn) SetWriteDeadline(t time.Time) error { 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /libplugin/ioconn/conn_test.go: -------------------------------------------------------------------------------- 1 | package ioconn_test 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "github.com/tg123/sshpiper/libplugin/ioconn" 8 | ) 9 | 10 | func TestDial(t *testing.T) { 11 | in, out := io.Pipe() 12 | 13 | conn, err := ioconn.Dial(in, out) 14 | if err != nil { 15 | t.Errorf("Dial returned an error: %v", err) 16 | } 17 | defer conn.Close() 18 | 19 | go func() { 20 | 21 | _, _ = conn.Write([]byte("hello")) 22 | }() 23 | buf := make([]byte, 5) 24 | _, _ = conn.Read(buf) 25 | 26 | if string(buf) != "hello" { 27 | t.Errorf("unexpected string read: %v", string(buf)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libplugin/ioconn/listener.go: -------------------------------------------------------------------------------- 1 | package ioconn 2 | 3 | import ( 4 | "io" 5 | "net" 6 | ) 7 | 8 | type singleConnListener struct { 9 | conn 10 | used chan int 11 | } 12 | 13 | // Accept implements net.Listener 14 | func (l *singleConnListener) Accept() (net.Conn, error) { 15 | <-l.used 16 | return &l.conn, nil 17 | } 18 | 19 | // Addr implements net.Listener 20 | func (l *singleConnListener) Addr() net.Addr { 21 | return l.LocalAddr() 22 | } 23 | 24 | // Close implements net.Listener 25 | func (l *singleConnListener) Close() error { 26 | return l.conn.Close() 27 | } 28 | 29 | // ListenFromSingleIO creates a net.Listener from a single input/output connection. 30 | // It takes an io.ReadCloser and an io.WriteCloser as parameters and returns a net.Listener and an error. 31 | // The returned net.Listener can be used to accept incoming connections. 32 | func ListenFromSingleIO(in io.ReadCloser, out io.WriteCloser) (net.Listener, error) { 33 | l := &singleConnListener{ 34 | conn{in, out}, 35 | make(chan int, 1), 36 | } 37 | 38 | l.used <- 1 // ready for accept 39 | return l, nil 40 | } 41 | -------------------------------------------------------------------------------- /libplugin/ioconn/listener_test.go: -------------------------------------------------------------------------------- 1 | package ioconn_test 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "github.com/tg123/sshpiper/libplugin/ioconn" 8 | ) 9 | 10 | func TestListenFromSingleIO(t *testing.T) { 11 | in, out := io.Pipe() 12 | 13 | l, err := ioconn.ListenFromSingleIO(in, out) 14 | if err != nil { 15 | t.Errorf("ListenFromSingleIO returned an error: %v", err) 16 | } 17 | 18 | conn, err := l.Accept() 19 | if err != nil { 20 | t.Errorf("Accept returned an error: %v", err) 21 | } 22 | 23 | defer conn.Close() 24 | defer l.Close() 25 | 26 | go func() { 27 | _, _ = conn.Write([]byte("hello")) 28 | }() 29 | 30 | buf := make([]byte, 5) 31 | _, _ = conn.Read(buf) 32 | if string(buf) != "hello" { 33 | t.Errorf("unexpected string read: %v", string(buf)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /libplugin/plugin.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package libplugin; 4 | 5 | option go_package = "github.com/tg123/sshpiper/libplugin"; 6 | 7 | message ConnMeta { 8 | string user_name = 1; 9 | string from_addr = 2; 10 | string uniq_id = 3; 11 | map metadata = 4; 12 | } 13 | 14 | message Upstream { 15 | string host = 1; 16 | int32 port = 2; 17 | string user_name = 3; 18 | bool ignore_host_key = 4; 19 | 20 | oneof auth { 21 | UpstreamNoneAuth none = 100; 22 | UpstreamPasswordAuth password = 101; 23 | UpstreamPrivateKeyAuth private_key = 102; 24 | UpstreamRemoteSignerAuth remote_signer = 103; 25 | UpstreamNextPluginAuth next_plugin = 200; 26 | UpstreamRetryCurrentPluginAuth retry_current_plugin = 201; 27 | } 28 | } 29 | 30 | message UpstreamNoneAuth { 31 | 32 | } 33 | 34 | message UpstreamPasswordAuth { 35 | string password = 1; 36 | } 37 | 38 | message UpstreamPrivateKeyAuth { 39 | bytes private_key = 1; 40 | bytes ca_public_key = 2; 41 | } 42 | 43 | message UpstreamRemoteSignerAuth{ 44 | string meta = 1; 45 | } 46 | 47 | message UpstreamNextPluginAuth { 48 | map meta = 1; 49 | } 50 | 51 | message UpstreamRetryCurrentPluginAuth { 52 | map meta = 1; 53 | } 54 | 55 | service SshPiperPlugin { 56 | rpc Logs(StartLogRequest) returns (stream Log) {} 57 | rpc ListCallbacks(ListCallbackRequest) returns (ListCallbackResponse) {} 58 | 59 | rpc NewConnection(NewConnectionRequest) returns (NewConnectionResponse) {} 60 | rpc NextAuthMethods(NextAuthMethodsRequest) returns (NextAuthMethodsResponse) {} 61 | rpc NoneAuth(NoneAuthRequest) returns (NoneAuthResponse) {} 62 | rpc PasswordAuth(PasswordAuthRequest) returns (PasswordAuthResponse) {} 63 | rpc PublicKeyAuth(PublicKeyAuthRequest) returns (PublicKeyAuthResponse) {} 64 | rpc KeyboardInteractiveAuth(stream KeyboardInteractiveAuthMessage) returns (stream KeyboardInteractiveAuthMessage) {} 65 | rpc UpstreamAuthFailureNotice(UpstreamAuthFailureNoticeRequest) returns (UpstreamAuthFailureNoticeResponse) {} 66 | rpc Banner(BannerRequest) returns (BannerResponse) {} 67 | rpc VerifyHostKey (VerifyHostKeyRequest) returns (VerifyHostKeyResponse) {} 68 | rpc PipeCreateErrorNotice(PipeCreateErrorNoticeRequest) returns (PipeCreateErrorNoticeResponse) {} 69 | rpc PipeStartNotice(PipeStartNoticeRequest) returns (PipeStartNoticeResponse) {} 70 | rpc PipeErrorNotice(PipeErrorNoticeRequest) returns (PipeErrorNoticeResponse) {} 71 | } 72 | 73 | message StartLogRequest { 74 | string uniq_id = 1; 75 | string level = 2; 76 | bool tty = 3; 77 | } 78 | 79 | message Log { 80 | string message = 1; 81 | } 82 | 83 | message ListCallbackRequest { 84 | } 85 | 86 | message ListCallbackResponse { 87 | repeated string callbacks = 1; 88 | } 89 | 90 | message NewConnectionRequest { 91 | ConnMeta meta = 1; 92 | } 93 | 94 | message NewConnectionResponse { 95 | } 96 | 97 | message NextAuthMethodsRequest { 98 | ConnMeta meta = 1; 99 | } 100 | 101 | enum AuthMethod { 102 | NONE = 0; 103 | PASSWORD = 1; 104 | PUBLICKEY = 2; 105 | KEYBOARD_INTERACTIVE = 3; 106 | } 107 | 108 | message NextAuthMethodsResponse { 109 | repeated AuthMethod methods = 1; 110 | } 111 | 112 | message NoneAuthRequest { 113 | ConnMeta meta = 1; 114 | } 115 | 116 | message NoneAuthResponse { 117 | Upstream upstream = 1; 118 | } 119 | 120 | message PasswordAuthRequest { 121 | ConnMeta meta = 1; 122 | bytes password = 2; 123 | } 124 | 125 | message PasswordAuthResponse { 126 | Upstream upstream = 1; 127 | } 128 | 129 | message PublicKeyAuthRequest { 130 | ConnMeta meta = 1; 131 | bytes public_key = 2; 132 | } 133 | 134 | message PublicKeyAuthResponse { 135 | Upstream upstream = 1; 136 | } 137 | 138 | message KeyboardInteractiveUserResponse { 139 | repeated string answers = 1; 140 | } 141 | 142 | message KeyboardInteractivePromptRequest { 143 | message Question{ 144 | string text = 1; 145 | bool echo = 2; 146 | } 147 | 148 | string name = 1; 149 | string instruction = 2; 150 | repeated Question questions = 3; 151 | } 152 | 153 | message KeyboardInteractiveMetaRequest { 154 | } 155 | 156 | message KeyboardInteractiveMetaResponse { 157 | ConnMeta meta = 1; 158 | } 159 | 160 | message KeyboardInteractiveFinishRequest { 161 | Upstream upstream = 1; 162 | } 163 | 164 | message KeyboardInteractiveAuthMessage { 165 | oneof message { 166 | KeyboardInteractivePromptRequest prompt_request = 1; 167 | KeyboardInteractiveUserResponse user_response = 2; 168 | KeyboardInteractiveMetaRequest meta_request = 3; 169 | KeyboardInteractiveMetaResponse meta_response = 4; 170 | KeyboardInteractiveFinishRequest finish_request = 5; 171 | } 172 | } 173 | 174 | message UpstreamAuthFailureNoticeRequest { 175 | ConnMeta meta = 1; 176 | string method = 2; 177 | string error = 3; 178 | repeated AuthMethod allowed_methods = 4; 179 | } 180 | 181 | message UpstreamAuthFailureNoticeResponse { 182 | } 183 | 184 | message BannerRequest { 185 | ConnMeta meta = 1; 186 | } 187 | 188 | message BannerResponse { 189 | string message = 1; 190 | } 191 | 192 | message VerifyHostKeyRequest { 193 | ConnMeta meta = 1; 194 | bytes key = 2; 195 | string hostname = 3; 196 | string netaddress = 4; 197 | } 198 | 199 | message VerifyHostKeyResponse { 200 | bool verified = 1; 201 | } 202 | 203 | message PipeStartNoticeRequest { 204 | ConnMeta meta = 1; 205 | } 206 | 207 | message PipeStartNoticeResponse { 208 | } 209 | 210 | message PipeErrorNoticeRequest { 211 | ConnMeta meta = 1; 212 | string error = 2; 213 | } 214 | 215 | message PipeErrorNoticeResponse { 216 | } 217 | 218 | message PipeCreateErrorNoticeRequest { 219 | string from_addr = 1; 220 | string error = 2; 221 | } 222 | 223 | message PipeCreateErrorNoticeResponse { 224 | } 225 | -------------------------------------------------------------------------------- /libplugin/template.go: -------------------------------------------------------------------------------- 1 | package libplugin 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | type PluginTemplate struct { 12 | Name string 13 | Usage string 14 | Flags []cli.Flag 15 | LogFormatter logrus.Formatter 16 | CreateConfig func(c *cli.Context) (*SshPiperPluginConfig, error) 17 | } 18 | 19 | func CreateAndRunPluginTemplate(t *PluginTemplate) { 20 | app := &cli.App{ 21 | Name: t.Name, 22 | Usage: t.Usage, 23 | Flags: t.Flags, 24 | HideHelpCommand: true, 25 | HideHelp: true, 26 | Writer: os.Stderr, 27 | ErrWriter: os.Stderr, 28 | Action: func(c *cli.Context) error { 29 | if t == nil { 30 | return fmt.Errorf("plugin template is nil") 31 | } 32 | 33 | if t.CreateConfig == nil { 34 | return fmt.Errorf("plugin template create config is nil") 35 | } 36 | 37 | config, err := t.CreateConfig(c) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | p, err := NewFromStdio(*config) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | ConfigStdioLogrus(p, t.LogFormatter, nil) 48 | return p.Serve() 49 | }, 50 | } 51 | 52 | if err := app.Run(os.Args); err != nil { 53 | fmt.Fprintf(os.Stderr, "cannot start plugin: %v\n", err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /libplugin/util.go: -------------------------------------------------------------------------------- 1 | package libplugin 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "strconv" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func AuthMethodTypeToName(a AuthMethod) string { 13 | switch a { 14 | case AuthMethod_NONE: 15 | return "none" 16 | case AuthMethod_PASSWORD: 17 | return "password" 18 | case AuthMethod_PUBLICKEY: 19 | return "publickey" 20 | case AuthMethod_KEYBOARD_INTERACTIVE: 21 | return "keyboard-interactive" 22 | } 23 | return "" 24 | } 25 | 26 | func AuthMethodFromName(n string) AuthMethod { 27 | switch n { 28 | case "none": 29 | return AuthMethod_NONE 30 | case "password": 31 | return AuthMethod_PASSWORD 32 | case "publickey": 33 | return AuthMethod_PUBLICKEY 34 | case "keyboard-interactive": 35 | return AuthMethod_KEYBOARD_INTERACTIVE 36 | } 37 | return -1 38 | } 39 | 40 | func ConfigStdioLogrus(p SshPiperPlugin, formatter logrus.Formatter, logger *logrus.Logger) { 41 | if logger == nil { 42 | logger = logrus.StandardLogger() 43 | } 44 | 45 | p.SetConfigLoggerCallback(func(w io.Writer, level string, tty bool) { 46 | logger.SetOutput(w) 47 | lv, _ := logrus.ParseLevel(level) 48 | logger.SetLevel(lv) 49 | 50 | if formatter != nil { 51 | logger.SetFormatter(formatter) 52 | } 53 | 54 | if tty { 55 | if formatter == nil { 56 | logger.SetFormatter(&logrus.TextFormatter{ForceColors: true}) 57 | } 58 | } 59 | }) 60 | } 61 | 62 | // SplitHostPortForSSH is the modified version of net.SplitHostPort but return port 22 is no port is specified 63 | func SplitHostPortForSSH(addr string) (host string, port int, err error) { 64 | host = addr 65 | h, p, err := net.SplitHostPort(host) 66 | if err == nil { 67 | host = h 68 | var parsedPort int64 69 | parsedPort, err = strconv.ParseInt(p, 10, 32) 70 | if err != nil { 71 | return 72 | } 73 | port = int(parsedPort) 74 | } else if host != "" { 75 | // test valid after concat :22 76 | if _, _, err = net.SplitHostPort(host + ":22"); err == nil { 77 | port = 22 78 | } 79 | } 80 | 81 | if host == "" { 82 | err = fmt.Errorf("empty addr") 83 | } 84 | 85 | return 86 | } 87 | 88 | // DialForSSH is the modified version of net.Dial, would add ":22" automaticlly 89 | func DialForSSH(addr string) (net.Conn, error) { 90 | if _, _, err := net.SplitHostPort(addr); err != nil && addr != "" { 91 | // test valid after concat :22 92 | if _, _, err := net.SplitHostPort(addr + ":22"); err == nil { 93 | addr += ":22" 94 | } 95 | } 96 | 97 | return net.Dial("tcp", addr) 98 | } 99 | 100 | func CreateNoneAuth() *Upstream_None { 101 | return &Upstream_None{ 102 | None: &UpstreamNoneAuth{}, 103 | } 104 | } 105 | 106 | func CreatePasswordAuth(password []byte) *Upstream_Password { 107 | return CreatePasswordAuthFromString(string(password)) 108 | } 109 | 110 | func CreatePasswordAuthFromString(password string) *Upstream_Password { 111 | return &Upstream_Password{ 112 | Password: &UpstreamPasswordAuth{ 113 | Password: password, 114 | }, 115 | } 116 | } 117 | 118 | func CreatePrivateKeyAuth(key []byte, optionalSignedCaPublicKey ...[]byte) *Upstream_PrivateKey { 119 | var caPublicKey []byte 120 | if len(optionalSignedCaPublicKey) > 0 { 121 | caPublicKey = optionalSignedCaPublicKey[0] 122 | } 123 | return &Upstream_PrivateKey{ 124 | PrivateKey: &UpstreamPrivateKeyAuth{ 125 | PrivateKey: key, 126 | CaPublicKey: caPublicKey, 127 | }, 128 | } 129 | } 130 | 131 | func CreateRemoteSignerAuth(meta string) *Upstream_RemoteSigner { 132 | return &Upstream_RemoteSigner{ 133 | RemoteSigner: &UpstreamRemoteSignerAuth{ 134 | Meta: meta, 135 | }, 136 | } 137 | } 138 | 139 | func CreateNextPluginAuth(meta map[string]string) *Upstream_NextPlugin { 140 | return &Upstream_NextPlugin{ 141 | NextPlugin: &UpstreamNextPluginAuth{ 142 | Meta: meta, 143 | }, 144 | } 145 | } 146 | 147 | func CreateRetryCurrentPluginAuth(meta map[string]string) *Upstream_RetryCurrentPlugin { 148 | return &Upstream_RetryCurrentPlugin{ 149 | RetryCurrentPlugin: &UpstreamRetryCurrentPluginAuth{ 150 | Meta: meta, 151 | }, 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /plugin/docker/README.md: -------------------------------------------------------------------------------- 1 | # docker plugin for sshpiperd 2 | 3 | This plugin queries dockerd for containers and creates pipes to them. 4 | 5 | ## Usage 6 | 7 | ``` 8 | sshpiperd docker 9 | ``` 10 | 11 | start a container with sshpiper labels 12 | 13 | ``` 14 | docker run -d -e USER_NAME=user -e USER_PASSWORD=pass -e PASSWORD_ACCESS=true -l sshpiper.username=pass -l sshpiper.container_username=user -l sshpiper.port=2222 lscr.io/linuxserver/openssh-server 15 | ``` 16 | 17 | connect to piper 18 | 19 | ``` 20 | ssh -l pass piper 21 | ``` 22 | 23 | ### Config docker connection 24 | 25 | Docker connection is configured with environment variables below: 26 | 27 | 28 | 29 | * DOCKER_HOST: to set the url to the docker server, default "unix:///var/run/docker.sock" 30 | * DOCKER_API_VERSION: to set the version of the API to reach, leave empty for latest. 31 | * DOCKER_CERT_PATH: to load the TLS certificates from. 32 | * DOCKER_TLS_VERIFY: to enable or disable TLS verification, off by default. 33 | 34 | ### Container Labels for plugin 35 | 36 | * sshpiper.username: username to filter containers by `downstream`'s username. left empty to auth with `authorized_keys` only. 37 | * sshpiper.container_username: username of container's sshd 38 | * sshpiper.port: port of container's sshd 39 | * sshpiper.authorized_keys: authorized_keys to verify against `downstream`. in base64 format 40 | * sshpiper.private_key: private_key to sent to container's sshd. in base64 format 41 | -------------------------------------------------------------------------------- /plugin/docker/docker.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "net" 9 | 10 | "github.com/docker/docker/api/types/container" 11 | "github.com/docker/docker/api/types/network" 12 | "github.com/docker/docker/client" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type pipe struct { 17 | ClientUsername string 18 | ContainerUsername string 19 | Host string 20 | AuthorizedKeys string 21 | PrivateKey string 22 | } 23 | 24 | type plugin struct { 25 | dockerCli *client.Client 26 | } 27 | 28 | func newDockerPlugin() (*plugin, error) { 29 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return &plugin{ 34 | dockerCli: cli, 35 | }, nil 36 | } 37 | 38 | func (p *plugin) list() ([]pipe, error) { 39 | // filter := filters.NewArgs() 40 | // filter.Add("label", fmt.Sprintf("sshpiper.username=%v", username)) 41 | 42 | containers, err := p.dockerCli.ContainerList(context.Background(), container.ListOptions{ 43 | // Filters: filter, 44 | }) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | var pipes []pipe 50 | for _, c := range containers { 51 | // TODO: support env? 52 | pipe := pipe{} 53 | pipe.ClientUsername = c.Labels["sshpiper.username"] 54 | pipe.ContainerUsername = c.Labels["sshpiper.container_username"] 55 | pipe.AuthorizedKeys = c.Labels["sshpiper.authorized_keys"] 56 | pipe.PrivateKey = c.Labels["sshpiper.private_key"] 57 | 58 | if pipe.ClientUsername == "" && pipe.AuthorizedKeys == "" { 59 | log.Debugf("skipping container %v without sshpiper.username or sshpiper.authorized_keys or sshpiper.private_key", c.ID) 60 | continue 61 | } 62 | 63 | if pipe.AuthorizedKeys != "" && pipe.PrivateKey == "" { 64 | log.Errorf("skipping container %v without sshpiper.private_key but has sshpiper.authorized_keys", c.ID) 65 | continue 66 | } 67 | 68 | var hostcandidates []*network.EndpointSettings 69 | 70 | for _, network := range c.NetworkSettings.Networks { 71 | if network.IPAddress != "" { 72 | hostcandidates = append(hostcandidates, network) 73 | } 74 | } 75 | 76 | if len(hostcandidates) == 0 { 77 | return nil, fmt.Errorf("no ip address found for container %v", c.ID) 78 | } 79 | 80 | // default to first one 81 | pipe.Host = hostcandidates[0].IPAddress 82 | 83 | if len(hostcandidates) > 1 { 84 | netname := c.Labels["sshpiper.network"] 85 | 86 | if netname == "" { 87 | return nil, fmt.Errorf("multiple networks found for container %v, please specify sshpiper.network", c.ID) 88 | } 89 | 90 | net, err := p.dockerCli.NetworkInspect(context.Background(), netname, network.InspectOptions{}) 91 | if err != nil { 92 | log.Warnf("cannot list network %v for container %v: %v", netname, c.ID, err) 93 | continue 94 | } 95 | 96 | for _, hostcandidate := range hostcandidates { 97 | if hostcandidate.NetworkID == net.ID { 98 | pipe.Host = hostcandidate.IPAddress 99 | break 100 | } 101 | } 102 | } 103 | 104 | port := c.Labels["sshpiper.port"] 105 | if port != "" { 106 | pipe.Host = net.JoinHostPort(pipe.Host, port) 107 | } 108 | 109 | pipes = append(pipes, pipe) 110 | } 111 | 112 | return pipes, nil 113 | } 114 | -------------------------------------------------------------------------------- /plugin/docker/main.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/tg123/sshpiper/libplugin" 7 | "github.com/tg123/sshpiper/libplugin/skel" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func main() { 12 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 13 | Name: "docker", 14 | Usage: "sshpiperd docker plugin, see config in https://github.com/tg123/sshpiper/tree/master/plugin/docker", 15 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 16 | plugin, err := newDockerPlugin() 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | skel := skel.NewSkelPlugin(plugin.listPipe) 22 | return skel.CreateConfig(), nil 23 | }, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /plugin/docker/skel.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/base64" 7 | 8 | "github.com/tg123/sshpiper/libplugin" 9 | "github.com/tg123/sshpiper/libplugin/skel" 10 | ) 11 | 12 | type skelpipeWrapper struct { 13 | plugin *plugin 14 | 15 | pipe *pipe 16 | } 17 | 18 | type skelpipeFromWrapper struct { 19 | skelpipeWrapper 20 | } 21 | 22 | type skelpipePasswordWrapper struct { 23 | skelpipeFromWrapper 24 | } 25 | 26 | type skelpipePublicKeyWrapper struct { 27 | skelpipeFromWrapper 28 | } 29 | 30 | type skelpipeToWrapper struct { 31 | skelpipeWrapper 32 | 33 | username string 34 | } 35 | 36 | type skelpipeToPasswordWrapper struct { 37 | skelpipeToWrapper 38 | } 39 | 40 | type skelpipeToPrivateKeyWrapper struct { 41 | skelpipeToWrapper 42 | } 43 | 44 | func (s *skelpipeWrapper) From() []skel.SkelPipeFrom { 45 | w := skelpipeFromWrapper{ 46 | skelpipeWrapper: *s, 47 | } 48 | 49 | if s.pipe.PrivateKey != "" || s.pipe.AuthorizedKeys != "" { 50 | return []skel.SkelPipeFrom{&skelpipePublicKeyWrapper{ 51 | skelpipeFromWrapper: w, 52 | }} 53 | } else { 54 | return []skel.SkelPipeFrom{&skelpipePasswordWrapper{ 55 | skelpipeFromWrapper: w, 56 | }} 57 | } 58 | } 59 | 60 | func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { 61 | return s.username 62 | } 63 | 64 | func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { 65 | return s.pipe.Host 66 | } 67 | 68 | func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { 69 | return true // TODO support this 70 | } 71 | 72 | func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { 73 | return nil, nil // TODO support this 74 | } 75 | 76 | func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (skel.SkelPipeTo, error) { 77 | user := conn.User() 78 | 79 | matched := s.pipe.ClientUsername == user || s.pipe.ClientUsername == "" 80 | targetuser := s.pipe.ContainerUsername 81 | 82 | if targetuser == "" { 83 | targetuser = user 84 | } 85 | 86 | if matched { 87 | 88 | if s.pipe.PrivateKey != "" { 89 | return &skelpipeToPrivateKeyWrapper{ 90 | skelpipeToWrapper: skelpipeToWrapper{ 91 | skelpipeWrapper: s.skelpipeWrapper, 92 | username: targetuser, 93 | }, 94 | }, nil 95 | } 96 | 97 | return &skelpipeToPasswordWrapper{ 98 | skelpipeToWrapper: skelpipeToWrapper{ 99 | skelpipeWrapper: s.skelpipeWrapper, 100 | username: targetuser, 101 | }, 102 | }, nil 103 | } 104 | 105 | return nil, nil 106 | } 107 | 108 | func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { 109 | return true, nil // do not test input password 110 | } 111 | 112 | func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { 113 | return base64.StdEncoding.DecodeString(s.pipe.AuthorizedKeys) 114 | } 115 | 116 | func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { 117 | return nil, nil // TODO support this 118 | } 119 | 120 | func (s *skelpipeToPrivateKeyWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { 121 | k, err := base64.StdEncoding.DecodeString(s.pipe.PrivateKey) 122 | if err != nil { 123 | return nil, nil, err 124 | } 125 | 126 | return k, nil, nil 127 | } 128 | 129 | func (s *skelpipeToPasswordWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { 130 | return nil, nil 131 | } 132 | 133 | func (p *plugin) listPipe(_ libplugin.ConnMetadata) ([]skel.SkelPipe, error) { 134 | dpipes, err := p.list() 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | var pipes []skel.SkelPipe 140 | for _, pipe := range dpipes { 141 | wrapper := &skelpipeWrapper{ 142 | plugin: p, 143 | pipe: &pipe, 144 | } 145 | pipes = append(pipes, wrapper) 146 | 147 | } 148 | 149 | return pipes, nil 150 | } 151 | -------------------------------------------------------------------------------- /plugin/failtoban/README.md: -------------------------------------------------------------------------------- 1 | # fail to ban for sshpiperd 2 | 3 | put ip to jail for a while after failed to login for several times. 4 | 5 | ## Usage 6 | 7 | put this plugin after other plugins, like: 8 | 9 | ``` 10 | sshpiperd
-- failtoban 11 | ``` 12 | 13 | 14 | ## Configuration 15 | 16 | * max-failures: max failures before ban, default 5 17 | * ban-duration: ban duration, default 1h -------------------------------------------------------------------------------- /plugin/failtoban/main.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "go4.org/netipx" 8 | "net" 9 | "net/netip" 10 | "os" 11 | "os/signal" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | gocache "github.com/patrickmn/go-cache" 17 | log "github.com/sirupsen/logrus" 18 | "github.com/tg123/sshpiper/libplugin" 19 | "github.com/urfave/cli/v2" 20 | ) 21 | 22 | func main() { 23 | 24 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 25 | Name: "failtoban", 26 | Usage: "failtoban plugin, block ip after too many auth failures", 27 | Flags: []cli.Flag{ 28 | &cli.IntFlag{ 29 | Name: "max-failures", 30 | Usage: "max failures", 31 | EnvVars: []string{"SSHPIPERD_FAILTOBAN_MAX_FAILURES"}, 32 | Value: 5, 33 | }, 34 | &cli.DurationFlag{ 35 | Name: "ban-duration", 36 | Usage: "ban duration", 37 | EnvVars: []string{"SSHPIPERD_FAILTOBAN_BAN_DURATION"}, 38 | Value: 60 * time.Minute, 39 | }, 40 | &cli.BoolFlag{ 41 | Name: "log-only", 42 | Usage: "log only mode, no ban, useful for working with other tools like fail2ban", 43 | EnvVars: []string{"SSHPIPERD_FAILTOBAN_LOG_ONLY"}, 44 | Value: false, 45 | }, 46 | &cli.StringSliceFlag{ 47 | Name: "ignore-ip", 48 | Usage: "ignore ip, will not ban host matches from these ip addresses", 49 | EnvVars: []string{"SSHPIPERD_FAILTOBAN_IGNORE_IP"}, 50 | Value: cli.NewStringSlice(), 51 | }, 52 | }, 53 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 54 | 55 | maxFailures := c.Int("max-failures") 56 | banDuration := c.Duration("ban-duration") 57 | logOnly := c.Bool("log-only") 58 | ignoreIP := c.StringSlice("ignore-ip") 59 | cache := gocache.New(banDuration, banDuration/2*3) 60 | whitelist := buildIPSet(ignoreIP) 61 | 62 | // register signal handler 63 | go func() { 64 | sigChan := make(chan os.Signal, 1) 65 | signal.Notify(sigChan, syscall.SIGHUP) 66 | 67 | for { 68 | <-sigChan 69 | cache.Flush() 70 | log.Info("failtoban: cache reset due to SIGHUP") 71 | } 72 | }() 73 | 74 | return &libplugin.SshPiperPluginConfig{ 75 | NoClientAuthCallback: func(conn libplugin.ConnMetadata) (*libplugin.Upstream, error) { 76 | // in case someone put the failtoban plugin before other plugins 77 | return &libplugin.Upstream{ 78 | Auth: libplugin.CreateNextPluginAuth(map[string]string{}), 79 | }, nil 80 | }, 81 | NewConnectionCallback: func(conn libplugin.ConnMetadata) error { 82 | if logOnly { 83 | return nil 84 | } 85 | 86 | ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) 87 | ip0, _ := netip.ParseAddr(ip) 88 | 89 | if whitelist.Contains(ip0) { 90 | log.Debugf("failtoban: %v in whitelist, ignored.", ip0) 91 | return nil 92 | } 93 | 94 | failed, found := cache.Get(ip) 95 | if !found { 96 | // init 97 | return cache.Add(ip, 0, banDuration) 98 | } 99 | 100 | if failed.(int) >= maxFailures { 101 | return fmt.Errorf("failtoban: ip %v too auth many failures", ip) 102 | } 103 | 104 | return nil 105 | }, 106 | UpstreamAuthFailureCallback: func(conn libplugin.ConnMetadata, method string, err error, allowmethods []string) { 107 | ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) 108 | ip0, _ := netip.ParseAddr(ip) 109 | 110 | if whitelist.Contains(ip0) { 111 | log.Debugf("failtoban: %v in whitelist, ignored.", ip0) 112 | return 113 | } 114 | 115 | failed, _ := cache.IncrementInt(ip, 1) 116 | log.Warnf("failtoban: %v auth failed. current status: fail %v times, max allowed %v", ip, failed, maxFailures) 117 | }, 118 | PipeCreateErrorCallback: func(remoteAddr string, err error) { 119 | ip, _, _ := net.SplitHostPort(remoteAddr) 120 | ip0, _ := netip.ParseAddr(ip) 121 | 122 | if whitelist.Contains(ip0) { 123 | log.Debugf("failtoban: %v in whitelist, ignored.", ip0) 124 | return 125 | } 126 | 127 | failed, _ := cache.IncrementInt(ip, 1) 128 | log.Warnf("failtoban: %v pipe create failed, reason %v. current status: fail %v times, max allowed %v", ip, err, failed, maxFailures) 129 | }, 130 | }, nil 131 | }, 132 | }) 133 | } 134 | 135 | func buildIPSet(cidrs []string) *netipx.IPSet { 136 | 137 | var ipsetBuilder netipx.IPSetBuilder 138 | 139 | for _, cidr := range cidrs { 140 | if strings.Contains(cidr, "/") { 141 | prefix, err := netip.ParsePrefix(cidr) 142 | if err != nil { 143 | log.Debugf("failtoban: error while parsing ignore IP: \n%v", err) 144 | continue 145 | } 146 | ipsetBuilder.AddPrefix(prefix) 147 | } else { 148 | ip, err := netip.ParseAddr(cidr) 149 | if err != nil { 150 | log.Debugf("failtoban: error while parsing ignore IP: \n%v", err) 151 | continue 152 | } 153 | ipsetBuilder.Add(ip) 154 | } 155 | } 156 | ipset, err := ipsetBuilder.IPSet() 157 | if err != nil { 158 | log.Debugf("failtoban: error while getting IPSet: \n%v", err) 159 | } 160 | return ipset 161 | } 162 | -------------------------------------------------------------------------------- /plugin/fixed/README.md: -------------------------------------------------------------------------------- 1 | # fixed plugin for sshpiperd 2 | 3 | Targeting the fixed sshd endpoint. Note, key mapping is not supported. which means you cannot use public to login to remote sshd. 4 | 5 | 6 | ## Usage 7 | 8 | ``` 9 | sshpiperd fixed --target 127.0.0.1:5522 10 | ``` -------------------------------------------------------------------------------- /plugin/fixed/main.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | log "github.com/sirupsen/logrus" 7 | "github.com/tg123/sshpiper/libplugin" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func main() { 12 | 13 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 14 | Name: "fixed", 15 | Usage: "sshpiperd fixed plugin, only password auth is supported", 16 | Flags: []cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "target", 19 | Usage: "target ssh endpoint address", 20 | EnvVars: []string{"SSHPIPERD_FIXED_TARGET"}, 21 | Required: true, 22 | }, 23 | }, 24 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 25 | target := c.String("target") 26 | 27 | host, port, err := libplugin.SplitHostPortForSSH(target) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &libplugin.SshPiperPluginConfig{ 33 | PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { 34 | log.Info("routing to ", target) 35 | return &libplugin.Upstream{ 36 | Host: host, 37 | Port: int32(port), 38 | IgnoreHostKey: true, 39 | Auth: libplugin.CreatePasswordAuth(password), 40 | }, nil 41 | }, 42 | }, nil 43 | }, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /plugin/kubernetes/README.md: -------------------------------------------------------------------------------- 1 | # kubernetes plugin for sshpiperd 2 | 3 | The kubernetes plugin for sshpiperd provides native kubernetes CRD integretion and allow you manage sshpiper by `kubectl get pipes` and `kubectl apply -f pipe.yaml` 4 | 5 | this plugin is inpsired by the [first version kubernetes plugin](https://github.com/pockost/sshpipe-k8s-lib/) for v0 sshpier by [pockost](https://github.com/pockost) 6 | 7 | ## Usage 8 | 9 | Start plugin with flag `--all-namespaces` or environment variable `SSHPIPERD_KUBERNETES_ALL_NAMESPACES=true` for cluster-wide usage, or it will listen to the namespace where it is in by default. 10 | 11 | Start plugin with flag `--kubeconfig` or environment variable `SSHPIPERD_KUBERNETES_KUBECONFIG=/path/to/kubeconfig` to specify the kubeconfig file. 12 | 13 | ### Helm 14 | 15 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/sshpiper)](https://artifacthub.io/packages/helm/sshpiper/sshpiper) 16 | 17 | 18 | ``` 19 | helm repo add sshpiper https://tg123.github.io/sshpiper-chart/ 20 | 21 | helm install my-sshpiper sshpiper/sshpiper --version 0.1.1 22 | ``` 23 | 24 | ### Manually 25 | 26 | #### Apply CRD definition 27 | 28 | ``` 29 | kubectl apply -f https://raw.githubusercontent.com/tg123/sshpiper/master/plugin/kubernetes/crd.yaml 30 | ``` 31 | 32 | most parameters are the same as in [yaml](../yaml/) 33 | 34 | A full sample can be found [here](sample.yaml) 35 | 36 | #### Create Service 37 | 38 | ``` 39 | # sshpiper service 40 | --- 41 | apiVersion: v1 42 | kind: Service 43 | metadata: 44 | name: sshpiper 45 | spec: 46 | selector: 47 | app: sshpiper 48 | ports: 49 | - protocol: TCP 50 | port: 2222 51 | --- 52 | apiVersion: v1 53 | data: 54 | server_key: | 55 | 56 | kind: Secret 57 | metadata: 58 | name: sshpiper-server-key 59 | type: Opaque 60 | --- 61 | apiVersion: apps/v1 62 | kind: Deployment 63 | metadata: 64 | name: sshpiper-deployment 65 | labels: 66 | app: sshpiper 67 | spec: 68 | replicas: 1 69 | selector: 70 | matchLabels: 71 | app: sshpiper 72 | template: 73 | metadata: 74 | labels: 75 | app: sshpiper 76 | spec: 77 | serviceAccountName: sshpiper-account 78 | containers: 79 | - name: sshpiper 80 | image: farmer1992/sshpiperd:latest 81 | ports: 82 | - containerPort: 2222 83 | env: 84 | - name: PLUGIN 85 | value: "kubernetes" 86 | - name: SSHPIPERD_SERVER_KEY 87 | value: "/serverkey/ssh_host_ed25519_key" 88 | - name: SSHPIPERD_LOG_LEVEL 89 | value: "trace" 90 | volumeMounts: 91 | - name: sshpiper-server-key 92 | mountPath: "/serverkey/" 93 | readOnly: true 94 | volumes: 95 | - name: sshpiper-server-key 96 | secret: 97 | secretName: sshpiper-server-key 98 | items: 99 | - key: server_key 100 | path: ssh_host_ed25519_key 101 | --- 102 | apiVersion: rbac.authorization.k8s.io/v1 103 | kind: Role 104 | metadata: 105 | name: sshpiper-reader 106 | rules: 107 | - apiGroups: [""] 108 | resources: ["secrets"] 109 | verbs: ["get"] 110 | - apiGroups: ["sshpiper.com"] 111 | resources: ["pipes"] 112 | verbs: ["get", "list", "watch"] 113 | --- 114 | apiVersion: rbac.authorization.k8s.io/v1 115 | kind: RoleBinding 116 | metadata: 117 | name: read-sshpiper 118 | subjects: 119 | - kind: ServiceAccount 120 | name: sshpiper-account 121 | roleRef: 122 | kind: Role 123 | name: sshpiper-reader 124 | apiGroup: rbac.authorization.k8s.io 125 | --- 126 | apiVersion: v1 127 | kind: ServiceAccount 128 | metadata: 129 | name: sshpiper-account 130 | ``` 131 | 132 | ### Create Pipes 133 | 134 | #### Create Password Pipe 135 | 136 | 137 | ``` 138 | apiVersion: sshpiper.com/v1beta1 139 | kind: Pipe 140 | metadata: 141 | name: pipe-password 142 | spec: 143 | from: 144 | - username: "password_simple" 145 | to: 146 | host: host-password:2222 147 | username: "user" 148 | ignore_hostkey: true 149 | ``` 150 | 151 | `ssh password_simple@piper_ip` will pipe to `user@host-password` 152 | 153 | 154 | #### Create Public Key Pipe 155 | 156 | `ssh piper_ip -i ` will pipe to `user@host-publickey` and login with secret `host-publickey-key` 157 | 158 | 159 | ``` 160 | apiVersion: v1 161 | data: 162 | ssh-privatekey: | 163 | 164 | kind: Secret 165 | metadata: 166 | name: host-publickey-key 167 | type: kubernetes.io/ssh-auth 168 | --- 169 | apiVersion: sshpiper.com/v1beta1 170 | kind: Pipe 171 | metadata: 172 | name: pipe-publickey 173 | spec: 174 | from: 175 | - username: ".*" # catch all 176 | username_regex_match: true 177 | authorized_keys_data: "base64_authorized_keys_data" 178 | to: 179 | host: host-publickey:2222 180 | username: "user" 181 | private_key_secret: 182 | name: host-publickey-key 183 | ignore_hostkey: true 184 | ``` 185 | -------------------------------------------------------------------------------- /plugin/kubernetes/apis/sshpiper/v1beta1/doc.go: -------------------------------------------------------------------------------- 1 | // +k8s:deepcopy-gen=package 2 | // +groupName=sshpiper 3 | package v1beta1 4 | -------------------------------------------------------------------------------- /plugin/kubernetes/apis/sshpiper/v1beta1/register.go: -------------------------------------------------------------------------------- 1 | package v1beta1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | "k8s.io/apimachinery/pkg/runtime" 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | ) 8 | 9 | // SchemeGroupVersion is group version used to register these objects 10 | var SchemeGroupVersion = schema.GroupVersion{ 11 | Group: "sshpiper.com", 12 | Version: "v1beta1", 13 | } 14 | 15 | // Resource takes an unqualified resource and returns a Group qualified GroupResource 16 | func Resource(resource string) schema.GroupResource { 17 | return SchemeGroupVersion.WithResource(resource).GroupResource() 18 | } 19 | 20 | var ( 21 | // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. 22 | SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) 23 | localSchemeBuilder = &SchemeBuilder 24 | AddToScheme = localSchemeBuilder.AddToScheme 25 | ) 26 | 27 | // Adds the list of known types to the given scheme. 28 | func addKnownTypes(scheme *runtime.Scheme) error { 29 | scheme.AddKnownTypes(SchemeGroupVersion, 30 | &Pipe{}, 31 | &PipeList{}, 32 | ) 33 | 34 | metav1.AddToGroupVersion(scheme, SchemeGroupVersion) 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /plugin/kubernetes/apis/sshpiper/v1beta1/types.go: -------------------------------------------------------------------------------- 1 | package v1beta1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // +genclient 9 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 10 | type Pipe struct { 11 | metav1.TypeMeta `json:",inline"` 12 | metav1.ObjectMeta `json:"metadata,omitempty"` 13 | 14 | Spec PipeSpec `json:"spec"` 15 | } 16 | 17 | type PipeSpec struct { 18 | From []FromSpec `json:"from"` 19 | To ToSpec `json:"to"` 20 | } 21 | 22 | type FromSpec struct { 23 | Username string `json:"username"` 24 | UsernameRegexMatch bool `json:"username_regex_match,omitempty"` 25 | AuthorizedKeysData string `json:"authorized_keys_data,omitempty"` 26 | AuthorizedKeysFile string `json:"authorized_keys_file,omitempty"` 27 | AuthorizedKeysSecret corev1.LocalObjectReference `json:"authorized_keys_secret,omitempty"` 28 | HtpasswdData string `json:"htpasswd_data,omitempty"` 29 | HtpasswdFile string `json:"htpasswd_file,omitempty"` 30 | } 31 | 32 | type ToSpec struct { 33 | Username string `json:"username,omitempty"` 34 | Host string `json:"host"` 35 | PrivateKeySecret corev1.LocalObjectReference `json:"private_key_secret,omitempty"` 36 | PasswordSecret corev1.LocalObjectReference `json:"password_secret,omitempty"` 37 | KnownHostsData string `json:"known_hosts_data,omitempty"` 38 | IgnoreHostkey bool `json:"ignore_hostkey,omitempty"` 39 | } 40 | 41 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 42 | type PipeList struct { 43 | metav1.TypeMeta `json:",inline"` 44 | metav1.ListMeta `json:"metadata"` 45 | 46 | Items []Pipe `json:"items"` 47 | } 48 | -------------------------------------------------------------------------------- /plugin/kubernetes/apis/sshpiper/v1beta1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | // Code generated by deepcopy-gen. DO NOT EDIT. 5 | 6 | package v1beta1 7 | 8 | import ( 9 | runtime "k8s.io/apimachinery/pkg/runtime" 10 | ) 11 | 12 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 13 | func (in *FromSpec) DeepCopyInto(out *FromSpec) { 14 | *out = *in 15 | out.AuthorizedKeysSecret = in.AuthorizedKeysSecret 16 | return 17 | } 18 | 19 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FromSpec. 20 | func (in *FromSpec) DeepCopy() *FromSpec { 21 | if in == nil { 22 | return nil 23 | } 24 | out := new(FromSpec) 25 | in.DeepCopyInto(out) 26 | return out 27 | } 28 | 29 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 30 | func (in *Pipe) DeepCopyInto(out *Pipe) { 31 | *out = *in 32 | out.TypeMeta = in.TypeMeta 33 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 34 | in.Spec.DeepCopyInto(&out.Spec) 35 | return 36 | } 37 | 38 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Pipe. 39 | func (in *Pipe) DeepCopy() *Pipe { 40 | if in == nil { 41 | return nil 42 | } 43 | out := new(Pipe) 44 | in.DeepCopyInto(out) 45 | return out 46 | } 47 | 48 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 49 | func (in *Pipe) DeepCopyObject() runtime.Object { 50 | if c := in.DeepCopy(); c != nil { 51 | return c 52 | } 53 | return nil 54 | } 55 | 56 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 57 | func (in *PipeList) DeepCopyInto(out *PipeList) { 58 | *out = *in 59 | out.TypeMeta = in.TypeMeta 60 | in.ListMeta.DeepCopyInto(&out.ListMeta) 61 | if in.Items != nil { 62 | in, out := &in.Items, &out.Items 63 | *out = make([]Pipe, len(*in)) 64 | for i := range *in { 65 | (*in)[i].DeepCopyInto(&(*out)[i]) 66 | } 67 | } 68 | return 69 | } 70 | 71 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipeList. 72 | func (in *PipeList) DeepCopy() *PipeList { 73 | if in == nil { 74 | return nil 75 | } 76 | out := new(PipeList) 77 | in.DeepCopyInto(out) 78 | return out 79 | } 80 | 81 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 82 | func (in *PipeList) DeepCopyObject() runtime.Object { 83 | if c := in.DeepCopy(); c != nil { 84 | return c 85 | } 86 | return nil 87 | } 88 | 89 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 90 | func (in *PipeSpec) DeepCopyInto(out *PipeSpec) { 91 | *out = *in 92 | if in.From != nil { 93 | in, out := &in.From, &out.From 94 | *out = make([]FromSpec, len(*in)) 95 | copy(*out, *in) 96 | } 97 | out.To = in.To 98 | return 99 | } 100 | 101 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipeSpec. 102 | func (in *PipeSpec) DeepCopy() *PipeSpec { 103 | if in == nil { 104 | return nil 105 | } 106 | out := new(PipeSpec) 107 | in.DeepCopyInto(out) 108 | return out 109 | } 110 | 111 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 112 | func (in *ToSpec) DeepCopyInto(out *ToSpec) { 113 | *out = *in 114 | out.PrivateKeySecret = in.PrivateKeySecret 115 | out.PasswordSecret = in.PasswordSecret 116 | return 117 | } 118 | 119 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToSpec. 120 | func (in *ToSpec) DeepCopy() *ToSpec { 121 | if in == nil { 122 | return nil 123 | } 124 | out := new(ToSpec) 125 | in.DeepCopyInto(out) 126 | return out 127 | } 128 | -------------------------------------------------------------------------------- /plugin/kubernetes/crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: pipes.sshpiper.com 5 | spec: 6 | group: sshpiper.com 7 | names: 8 | kind: Pipe 9 | listKind: PipeList 10 | plural: pipes 11 | singular: pipe 12 | scope: Namespaced 13 | versions: 14 | - name: v1beta1 15 | schema: 16 | openAPIV3Schema: 17 | properties: 18 | apiVersion: 19 | description: 'APIVersion defines the versioned schema of this representation 20 | of an object. Servers should convert recognized schemas to the latest 21 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 22 | type: string 23 | kind: 24 | description: 'Kind is a string value representing the REST resource this 25 | object represents. Servers may infer this from the endpoint the client 26 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 27 | type: string 28 | metadata: 29 | type: object 30 | spec: 31 | properties: 32 | from: 33 | items: 34 | properties: 35 | authorized_keys_data: 36 | type: string 37 | authorized_keys_file: 38 | type: string 39 | authorized_keys_secret: 40 | description: LocalObjectReference contains enough information 41 | to let you locate the referenced object inside the same namespace. 42 | properties: 43 | name: 44 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 45 | TODO: Add other useful fields. apiVersion, kind, uid?' 46 | type: string 47 | type: object 48 | username: 49 | type: string 50 | htpasswd_data: 51 | type: string 52 | htpasswd_file: 53 | type: string 54 | username_regex_match: 55 | type: boolean 56 | required: 57 | - username 58 | type: object 59 | type: array 60 | to: 61 | properties: 62 | host: 63 | type: string 64 | ignore_hostkey: 65 | type: boolean 66 | known_hosts_data: 67 | type: string 68 | private_key_secret: 69 | description: LocalObjectReference contains enough information 70 | to let you locate the referenced object inside the same namespace. 71 | properties: 72 | name: 73 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 74 | TODO: Add other useful fields. apiVersion, kind, uid?' 75 | type: string 76 | type: object 77 | password_secret: 78 | description: LocalObjectReference contains enough information 79 | to let you locate the referenced object inside the same namespace. 80 | properties: 81 | name: 82 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 83 | TODO: Add other useful fields. apiVersion, kind, uid?' 84 | type: string 85 | type: object 86 | username: 87 | type: string 88 | required: 89 | - host 90 | type: object 91 | required: 92 | - from 93 | - to 94 | type: object 95 | required: 96 | - spec 97 | type: object 98 | additionalPrinterColumns: 99 | - jsonPath: .spec.from[0].username 100 | name: FromUser 101 | type: string 102 | - jsonPath: .spec.to.username 103 | name: ToUser 104 | type: string 105 | - jsonPath: .spec.to.host 106 | name: ToHost 107 | type: string 108 | 109 | served: true 110 | storage: true 111 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/clientset.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package versioned 4 | 5 | import ( 6 | fmt "fmt" 7 | http "net/http" 8 | 9 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1" 10 | discovery "k8s.io/client-go/discovery" 11 | rest "k8s.io/client-go/rest" 12 | flowcontrol "k8s.io/client-go/util/flowcontrol" 13 | ) 14 | 15 | type Interface interface { 16 | Discovery() discovery.DiscoveryInterface 17 | SshpiperV1beta1() sshpiperv1beta1.SshpiperV1beta1Interface 18 | } 19 | 20 | // Clientset contains the clients for groups. 21 | type Clientset struct { 22 | *discovery.DiscoveryClient 23 | sshpiperV1beta1 *sshpiperv1beta1.SshpiperV1beta1Client 24 | } 25 | 26 | // SshpiperV1beta1 retrieves the SshpiperV1beta1Client 27 | func (c *Clientset) SshpiperV1beta1() sshpiperv1beta1.SshpiperV1beta1Interface { 28 | return c.sshpiperV1beta1 29 | } 30 | 31 | // Discovery retrieves the DiscoveryClient 32 | func (c *Clientset) Discovery() discovery.DiscoveryInterface { 33 | if c == nil { 34 | return nil 35 | } 36 | return c.DiscoveryClient 37 | } 38 | 39 | // NewForConfig creates a new Clientset for the given config. 40 | // If config's RateLimiter is not set and QPS and Burst are acceptable, 41 | // NewForConfig will generate a rate-limiter in configShallowCopy. 42 | // NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), 43 | // where httpClient was generated with rest.HTTPClientFor(c). 44 | func NewForConfig(c *rest.Config) (*Clientset, error) { 45 | configShallowCopy := *c 46 | 47 | if configShallowCopy.UserAgent == "" { 48 | configShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent() 49 | } 50 | 51 | // share the transport between all clients 52 | httpClient, err := rest.HTTPClientFor(&configShallowCopy) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return NewForConfigAndClient(&configShallowCopy, httpClient) 58 | } 59 | 60 | // NewForConfigAndClient creates a new Clientset for the given config and http client. 61 | // Note the http client provided takes precedence over the configured transport values. 62 | // If config's RateLimiter is not set and QPS and Burst are acceptable, 63 | // NewForConfigAndClient will generate a rate-limiter in configShallowCopy. 64 | func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, error) { 65 | configShallowCopy := *c 66 | if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { 67 | if configShallowCopy.Burst <= 0 { 68 | return nil, fmt.Errorf("burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0") 69 | } 70 | configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) 71 | } 72 | 73 | var cs Clientset 74 | var err error 75 | cs.sshpiperV1beta1, err = sshpiperv1beta1.NewForConfigAndClient(&configShallowCopy, httpClient) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfigAndClient(&configShallowCopy, httpClient) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return &cs, nil 85 | } 86 | 87 | // NewForConfigOrDie creates a new Clientset for the given config and 88 | // panics if there is an error in the config. 89 | func NewForConfigOrDie(c *rest.Config) *Clientset { 90 | cs, err := NewForConfig(c) 91 | if err != nil { 92 | panic(err) 93 | } 94 | return cs 95 | } 96 | 97 | // New creates a new Clientset for the given RESTClient. 98 | func New(c rest.Interface) *Clientset { 99 | var cs Clientset 100 | cs.sshpiperV1beta1 = sshpiperv1beta1.New(c) 101 | 102 | cs.DiscoveryClient = discovery.NewDiscoveryClient(c) 103 | return &cs 104 | } 105 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/fake/clientset_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package fake 4 | 5 | import ( 6 | clientset "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned" 7 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1" 8 | fakesshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/fake" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/apimachinery/pkg/watch" 11 | "k8s.io/client-go/discovery" 12 | fakediscovery "k8s.io/client-go/discovery/fake" 13 | "k8s.io/client-go/testing" 14 | ) 15 | 16 | // NewSimpleClientset returns a clientset that will respond with the provided objects. 17 | // It's backed by a very simple object tracker that processes creates, updates and deletions as-is, 18 | // without applying any field management, validations and/or defaults. It shouldn't be considered a replacement 19 | // for a real clientset and is mostly useful in simple unit tests. 20 | // 21 | // DEPRECATED: NewClientset replaces this with support for field management, which significantly improves 22 | // server side apply testing. NewClientset is only available when apply configurations are generated (e.g. 23 | // via --with-applyconfig). 24 | func NewSimpleClientset(objects ...runtime.Object) *Clientset { 25 | o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) 26 | for _, obj := range objects { 27 | if err := o.Add(obj); err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | cs := &Clientset{tracker: o} 33 | cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake} 34 | cs.AddReactor("*", "*", testing.ObjectReaction(o)) 35 | cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { 36 | gvr := action.GetResource() 37 | ns := action.GetNamespace() 38 | watch, err := o.Watch(gvr, ns) 39 | if err != nil { 40 | return false, nil, err 41 | } 42 | return true, watch, nil 43 | }) 44 | 45 | return cs 46 | } 47 | 48 | // Clientset implements clientset.Interface. Meant to be embedded into a 49 | // struct to get a default implementation. This makes faking out just the method 50 | // you want to test easier. 51 | type Clientset struct { 52 | testing.Fake 53 | discovery *fakediscovery.FakeDiscovery 54 | tracker testing.ObjectTracker 55 | } 56 | 57 | func (c *Clientset) Discovery() discovery.DiscoveryInterface { 58 | return c.discovery 59 | } 60 | 61 | func (c *Clientset) Tracker() testing.ObjectTracker { 62 | return c.tracker 63 | } 64 | 65 | var ( 66 | _ clientset.Interface = &Clientset{} 67 | _ testing.FakeClient = &Clientset{} 68 | ) 69 | 70 | // SshpiperV1beta1 retrieves the SshpiperV1beta1Client 71 | func (c *Clientset) SshpiperV1beta1() sshpiperv1beta1.SshpiperV1beta1Interface { 72 | return &fakesshpiperv1beta1.FakeSshpiperV1beta1{Fake: &c.Fake} 73 | } 74 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/fake/doc.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | // This package has the automatically generated fake clientset. 4 | package fake 5 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/fake/register.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package fake 4 | 5 | import ( 6 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 7 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | schema "k8s.io/apimachinery/pkg/runtime/schema" 10 | serializer "k8s.io/apimachinery/pkg/runtime/serializer" 11 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 12 | ) 13 | 14 | var scheme = runtime.NewScheme() 15 | var codecs = serializer.NewCodecFactory(scheme) 16 | 17 | var localSchemeBuilder = runtime.SchemeBuilder{ 18 | sshpiperv1beta1.AddToScheme, 19 | } 20 | 21 | // AddToScheme adds all types of this clientset into the given scheme. This allows composition 22 | // of clientsets, like in: 23 | // 24 | // import ( 25 | // "k8s.io/client-go/kubernetes" 26 | // clientsetscheme "k8s.io/client-go/kubernetes/scheme" 27 | // aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" 28 | // ) 29 | // 30 | // kclientset, _ := kubernetes.NewForConfig(c) 31 | // _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) 32 | // 33 | // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types 34 | // correctly. 35 | var AddToScheme = localSchemeBuilder.AddToScheme 36 | 37 | func init() { 38 | v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) 39 | utilruntime.Must(AddToScheme(scheme)) 40 | } 41 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/scheme/doc.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | // This package contains the scheme of the automatically generated clientset. 4 | package scheme 5 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/scheme/register.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package scheme 4 | 5 | import ( 6 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 7 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | schema "k8s.io/apimachinery/pkg/runtime/schema" 10 | serializer "k8s.io/apimachinery/pkg/runtime/serializer" 11 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 12 | ) 13 | 14 | var Scheme = runtime.NewScheme() 15 | var Codecs = serializer.NewCodecFactory(Scheme) 16 | var ParameterCodec = runtime.NewParameterCodec(Scheme) 17 | var localSchemeBuilder = runtime.SchemeBuilder{ 18 | sshpiperv1beta1.AddToScheme, 19 | } 20 | 21 | // AddToScheme adds all types of this clientset into the given scheme. This allows composition 22 | // of clientsets, like in: 23 | // 24 | // import ( 25 | // "k8s.io/client-go/kubernetes" 26 | // clientsetscheme "k8s.io/client-go/kubernetes/scheme" 27 | // aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" 28 | // ) 29 | // 30 | // kclientset, _ := kubernetes.NewForConfig(c) 31 | // _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) 32 | // 33 | // After this, RawExtensions in Kubernetes types will serialize kube-aggregator types 34 | // correctly. 35 | var AddToScheme = localSchemeBuilder.AddToScheme 36 | 37 | func init() { 38 | v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) 39 | utilruntime.Must(AddToScheme(Scheme)) 40 | } 41 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/doc.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | // This package has the automatically generated typed clients. 4 | package v1beta1 5 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/fake/doc.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | // Package fake has the automatically generated clients. 4 | package fake 5 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/fake/fake_pipe.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package fake 4 | 5 | import ( 6 | v1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 7 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1" 8 | gentype "k8s.io/client-go/gentype" 9 | ) 10 | 11 | // fakePipes implements PipeInterface 12 | type fakePipes struct { 13 | *gentype.FakeClientWithList[*v1beta1.Pipe, *v1beta1.PipeList] 14 | Fake *FakeSshpiperV1beta1 15 | } 16 | 17 | func newFakePipes(fake *FakeSshpiperV1beta1, namespace string) sshpiperv1beta1.PipeInterface { 18 | return &fakePipes{ 19 | gentype.NewFakeClientWithList[*v1beta1.Pipe, *v1beta1.PipeList]( 20 | fake.Fake, 21 | namespace, 22 | v1beta1.SchemeGroupVersion.WithResource("pipes"), 23 | v1beta1.SchemeGroupVersion.WithKind("Pipe"), 24 | func() *v1beta1.Pipe { return &v1beta1.Pipe{} }, 25 | func() *v1beta1.PipeList { return &v1beta1.PipeList{} }, 26 | func(dst, src *v1beta1.PipeList) { dst.ListMeta = src.ListMeta }, 27 | func(list *v1beta1.PipeList) []*v1beta1.Pipe { return gentype.ToPointerSlice(list.Items) }, 28 | func(list *v1beta1.PipeList, items []*v1beta1.Pipe) { list.Items = gentype.FromPointerSlice(items) }, 29 | ), 30 | fake, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/fake/fake_sshpiper_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package fake 4 | 5 | import ( 6 | v1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1" 7 | rest "k8s.io/client-go/rest" 8 | testing "k8s.io/client-go/testing" 9 | ) 10 | 11 | type FakeSshpiperV1beta1 struct { 12 | *testing.Fake 13 | } 14 | 15 | func (c *FakeSshpiperV1beta1) Pipes(namespace string) v1beta1.PipeInterface { 16 | return newFakePipes(c, namespace) 17 | } 18 | 19 | // RESTClient returns a RESTClient that is used to communicate 20 | // with API server by this client implementation. 21 | func (c *FakeSshpiperV1beta1) RESTClient() rest.Interface { 22 | var ret *rest.RESTClient 23 | return ret 24 | } 25 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/generated_expansion.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | type PipeExpansion interface{} 6 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/pipe.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | import ( 6 | context "context" 7 | 8 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 9 | scheme "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/scheme" 10 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | types "k8s.io/apimachinery/pkg/types" 12 | watch "k8s.io/apimachinery/pkg/watch" 13 | gentype "k8s.io/client-go/gentype" 14 | ) 15 | 16 | // PipesGetter has a method to return a PipeInterface. 17 | // A group's client should implement this interface. 18 | type PipesGetter interface { 19 | Pipes(namespace string) PipeInterface 20 | } 21 | 22 | // PipeInterface has methods to work with Pipe resources. 23 | type PipeInterface interface { 24 | Create(ctx context.Context, pipe *sshpiperv1beta1.Pipe, opts v1.CreateOptions) (*sshpiperv1beta1.Pipe, error) 25 | Update(ctx context.Context, pipe *sshpiperv1beta1.Pipe, opts v1.UpdateOptions) (*sshpiperv1beta1.Pipe, error) 26 | Delete(ctx context.Context, name string, opts v1.DeleteOptions) error 27 | DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error 28 | Get(ctx context.Context, name string, opts v1.GetOptions) (*sshpiperv1beta1.Pipe, error) 29 | List(ctx context.Context, opts v1.ListOptions) (*sshpiperv1beta1.PipeList, error) 30 | Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) 31 | Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *sshpiperv1beta1.Pipe, err error) 32 | PipeExpansion 33 | } 34 | 35 | // pipes implements PipeInterface 36 | type pipes struct { 37 | *gentype.ClientWithList[*sshpiperv1beta1.Pipe, *sshpiperv1beta1.PipeList] 38 | } 39 | 40 | // newPipes returns a Pipes 41 | func newPipes(c *SshpiperV1beta1Client, namespace string) *pipes { 42 | return &pipes{ 43 | gentype.NewClientWithList[*sshpiperv1beta1.Pipe, *sshpiperv1beta1.PipeList]( 44 | "pipes", 45 | c.RESTClient(), 46 | scheme.ParameterCodec, 47 | namespace, 48 | func() *sshpiperv1beta1.Pipe { return &sshpiperv1beta1.Pipe{} }, 49 | func() *sshpiperv1beta1.PipeList { return &sshpiperv1beta1.PipeList{} }, 50 | ), 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/clientset/versioned/typed/sshpiper/v1beta1/sshpiper_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by client-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | import ( 6 | http "net/http" 7 | 8 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 9 | scheme "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned/scheme" 10 | rest "k8s.io/client-go/rest" 11 | ) 12 | 13 | type SshpiperV1beta1Interface interface { 14 | RESTClient() rest.Interface 15 | PipesGetter 16 | } 17 | 18 | // SshpiperV1beta1Client is used to interact with features provided by the sshpiper group. 19 | type SshpiperV1beta1Client struct { 20 | restClient rest.Interface 21 | } 22 | 23 | func (c *SshpiperV1beta1Client) Pipes(namespace string) PipeInterface { 24 | return newPipes(c, namespace) 25 | } 26 | 27 | // NewForConfig creates a new SshpiperV1beta1Client for the given config. 28 | // NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), 29 | // where httpClient was generated with rest.HTTPClientFor(c). 30 | func NewForConfig(c *rest.Config) (*SshpiperV1beta1Client, error) { 31 | config := *c 32 | if err := setConfigDefaults(&config); err != nil { 33 | return nil, err 34 | } 35 | httpClient, err := rest.HTTPClientFor(&config) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return NewForConfigAndClient(&config, httpClient) 40 | } 41 | 42 | // NewForConfigAndClient creates a new SshpiperV1beta1Client for the given config and http client. 43 | // Note the http client provided takes precedence over the configured transport values. 44 | func NewForConfigAndClient(c *rest.Config, h *http.Client) (*SshpiperV1beta1Client, error) { 45 | config := *c 46 | if err := setConfigDefaults(&config); err != nil { 47 | return nil, err 48 | } 49 | client, err := rest.RESTClientForConfigAndClient(&config, h) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return &SshpiperV1beta1Client{client}, nil 54 | } 55 | 56 | // NewForConfigOrDie creates a new SshpiperV1beta1Client for the given config and 57 | // panics if there is an error in the config. 58 | func NewForConfigOrDie(c *rest.Config) *SshpiperV1beta1Client { 59 | client, err := NewForConfig(c) 60 | if err != nil { 61 | panic(err) 62 | } 63 | return client 64 | } 65 | 66 | // New creates a new SshpiperV1beta1Client for the given RESTClient. 67 | func New(c rest.Interface) *SshpiperV1beta1Client { 68 | return &SshpiperV1beta1Client{c} 69 | } 70 | 71 | func setConfigDefaults(config *rest.Config) error { 72 | gv := sshpiperv1beta1.SchemeGroupVersion 73 | config.GroupVersion = &gv 74 | config.APIPath = "/apis" 75 | config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion() 76 | 77 | if config.UserAgent == "" { 78 | config.UserAgent = rest.DefaultKubernetesUserAgent() 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // RESTClient returns a RESTClient that is used to communicate 85 | // with API server by this client implementation. 86 | func (c *SshpiperV1beta1Client) RESTClient() rest.Interface { 87 | if c == nil { 88 | return nil 89 | } 90 | return c.restClient 91 | } 92 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/informers/externalversions/generic.go: -------------------------------------------------------------------------------- 1 | // Code generated by informer-gen. DO NOT EDIT. 2 | 3 | package externalversions 4 | 5 | import ( 6 | fmt "fmt" 7 | 8 | v1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 9 | schema "k8s.io/apimachinery/pkg/runtime/schema" 10 | cache "k8s.io/client-go/tools/cache" 11 | ) 12 | 13 | // GenericInformer is type of SharedIndexInformer which will locate and delegate to other 14 | // sharedInformers based on type 15 | type GenericInformer interface { 16 | Informer() cache.SharedIndexInformer 17 | Lister() cache.GenericLister 18 | } 19 | 20 | type genericInformer struct { 21 | informer cache.SharedIndexInformer 22 | resource schema.GroupResource 23 | } 24 | 25 | // Informer returns the SharedIndexInformer. 26 | func (f *genericInformer) Informer() cache.SharedIndexInformer { 27 | return f.informer 28 | } 29 | 30 | // Lister returns the GenericLister. 31 | func (f *genericInformer) Lister() cache.GenericLister { 32 | return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) 33 | } 34 | 35 | // ForResource gives generic access to a shared informer of the matching type 36 | // TODO extend this to unknown resources with a client pool 37 | func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { 38 | switch resource { 39 | // Group=sshpiper, Version=v1beta1 40 | case v1beta1.SchemeGroupVersion.WithResource("pipes"): 41 | return &genericInformer{resource: resource.GroupResource(), informer: f.Sshpiper().V1beta1().Pipes().Informer()}, nil 42 | 43 | } 44 | 45 | return nil, fmt.Errorf("no informer found for %v", resource) 46 | } 47 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/informers/externalversions/internalinterfaces/factory_interfaces.go: -------------------------------------------------------------------------------- 1 | // Code generated by informer-gen. DO NOT EDIT. 2 | 3 | package internalinterfaces 4 | 5 | import ( 6 | time "time" 7 | 8 | versioned "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned" 9 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | runtime "k8s.io/apimachinery/pkg/runtime" 11 | cache "k8s.io/client-go/tools/cache" 12 | ) 13 | 14 | // NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer. 15 | type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer 16 | 17 | // SharedInformerFactory a small interface to allow for adding an informer without an import cycle 18 | type SharedInformerFactory interface { 19 | Start(stopCh <-chan struct{}) 20 | InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer 21 | } 22 | 23 | // TweakListOptionsFunc is a function that transforms a v1.ListOptions. 24 | type TweakListOptionsFunc func(*v1.ListOptions) 25 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/informers/externalversions/sshpiper/interface.go: -------------------------------------------------------------------------------- 1 | // Code generated by informer-gen. DO NOT EDIT. 2 | 3 | package sshpiper 4 | 5 | import ( 6 | internalinterfaces "github.com/tg123/sshpiper/plugin/kubernetes/generated/informers/externalversions/internalinterfaces" 7 | v1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/informers/externalversions/sshpiper/v1beta1" 8 | ) 9 | 10 | // Interface provides access to each of this group's versions. 11 | type Interface interface { 12 | // V1beta1 provides access to shared informers for resources in V1beta1. 13 | V1beta1() v1beta1.Interface 14 | } 15 | 16 | type group struct { 17 | factory internalinterfaces.SharedInformerFactory 18 | namespace string 19 | tweakListOptions internalinterfaces.TweakListOptionsFunc 20 | } 21 | 22 | // New returns a new Interface. 23 | func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { 24 | return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} 25 | } 26 | 27 | // V1beta1 returns a new v1beta1.Interface. 28 | func (g *group) V1beta1() v1beta1.Interface { 29 | return v1beta1.New(g.factory, g.namespace, g.tweakListOptions) 30 | } 31 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/informers/externalversions/sshpiper/v1beta1/interface.go: -------------------------------------------------------------------------------- 1 | // Code generated by informer-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | import ( 6 | internalinterfaces "github.com/tg123/sshpiper/plugin/kubernetes/generated/informers/externalversions/internalinterfaces" 7 | ) 8 | 9 | // Interface provides access to all the informers in this group version. 10 | type Interface interface { 11 | // Pipes returns a PipeInformer. 12 | Pipes() PipeInformer 13 | } 14 | 15 | type version struct { 16 | factory internalinterfaces.SharedInformerFactory 17 | namespace string 18 | tweakListOptions internalinterfaces.TweakListOptionsFunc 19 | } 20 | 21 | // New returns a new Interface. 22 | func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { 23 | return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} 24 | } 25 | 26 | // Pipes returns a PipeInformer. 27 | func (v *version) Pipes() PipeInformer { 28 | return &pipeInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} 29 | } 30 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/informers/externalversions/sshpiper/v1beta1/pipe.go: -------------------------------------------------------------------------------- 1 | // Code generated by informer-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | import ( 6 | context "context" 7 | time "time" 8 | 9 | apissshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 10 | versioned "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned" 11 | internalinterfaces "github.com/tg123/sshpiper/plugin/kubernetes/generated/informers/externalversions/internalinterfaces" 12 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/generated/listers/sshpiper/v1beta1" 13 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | runtime "k8s.io/apimachinery/pkg/runtime" 15 | watch "k8s.io/apimachinery/pkg/watch" 16 | cache "k8s.io/client-go/tools/cache" 17 | ) 18 | 19 | // PipeInformer provides access to a shared informer and lister for 20 | // Pipes. 21 | type PipeInformer interface { 22 | Informer() cache.SharedIndexInformer 23 | Lister() sshpiperv1beta1.PipeLister 24 | } 25 | 26 | type pipeInformer struct { 27 | factory internalinterfaces.SharedInformerFactory 28 | tweakListOptions internalinterfaces.TweakListOptionsFunc 29 | namespace string 30 | } 31 | 32 | // NewPipeInformer constructs a new informer for Pipe type. 33 | // Always prefer using an informer factory to get a shared informer instead of getting an independent 34 | // one. This reduces memory footprint and number of connections to the server. 35 | func NewPipeInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { 36 | return NewFilteredPipeInformer(client, namespace, resyncPeriod, indexers, nil) 37 | } 38 | 39 | // NewFilteredPipeInformer constructs a new informer for Pipe type. 40 | // Always prefer using an informer factory to get a shared informer instead of getting an independent 41 | // one. This reduces memory footprint and number of connections to the server. 42 | func NewFilteredPipeInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { 43 | return cache.NewSharedIndexInformer( 44 | &cache.ListWatch{ 45 | ListFunc: func(options v1.ListOptions) (runtime.Object, error) { 46 | if tweakListOptions != nil { 47 | tweakListOptions(&options) 48 | } 49 | return client.SshpiperV1beta1().Pipes(namespace).List(context.TODO(), options) 50 | }, 51 | WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { 52 | if tweakListOptions != nil { 53 | tweakListOptions(&options) 54 | } 55 | return client.SshpiperV1beta1().Pipes(namespace).Watch(context.TODO(), options) 56 | }, 57 | }, 58 | &apissshpiperv1beta1.Pipe{}, 59 | resyncPeriod, 60 | indexers, 61 | ) 62 | } 63 | 64 | func (f *pipeInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { 65 | return NewFilteredPipeInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) 66 | } 67 | 68 | func (f *pipeInformer) Informer() cache.SharedIndexInformer { 69 | return f.factory.InformerFor(&apissshpiperv1beta1.Pipe{}, f.defaultInformer) 70 | } 71 | 72 | func (f *pipeInformer) Lister() sshpiperv1beta1.PipeLister { 73 | return sshpiperv1beta1.NewPipeLister(f.Informer().GetIndexer()) 74 | } 75 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/listers/sshpiper/v1beta1/expansion_generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by lister-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | // PipeListerExpansion allows custom methods to be added to 6 | // PipeLister. 7 | type PipeListerExpansion interface{} 8 | 9 | // PipeNamespaceListerExpansion allows custom methods to be added to 10 | // PipeNamespaceLister. 11 | type PipeNamespaceListerExpansion interface{} 12 | -------------------------------------------------------------------------------- /plugin/kubernetes/generated/listers/sshpiper/v1beta1/pipe.go: -------------------------------------------------------------------------------- 1 | // Code generated by lister-gen. DO NOT EDIT. 2 | 3 | package v1beta1 4 | 5 | import ( 6 | sshpiperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 7 | labels "k8s.io/apimachinery/pkg/labels" 8 | listers "k8s.io/client-go/listers" 9 | cache "k8s.io/client-go/tools/cache" 10 | ) 11 | 12 | // PipeLister helps list Pipes. 13 | // All objects returned here must be treated as read-only. 14 | type PipeLister interface { 15 | // List lists all Pipes in the indexer. 16 | // Objects returned here must be treated as read-only. 17 | List(selector labels.Selector) (ret []*sshpiperv1beta1.Pipe, err error) 18 | // Pipes returns an object that can list and get Pipes. 19 | Pipes(namespace string) PipeNamespaceLister 20 | PipeListerExpansion 21 | } 22 | 23 | // pipeLister implements the PipeLister interface. 24 | type pipeLister struct { 25 | listers.ResourceIndexer[*sshpiperv1beta1.Pipe] 26 | } 27 | 28 | // NewPipeLister returns a new PipeLister. 29 | func NewPipeLister(indexer cache.Indexer) PipeLister { 30 | return &pipeLister{listers.New[*sshpiperv1beta1.Pipe](indexer, sshpiperv1beta1.Resource("pipe"))} 31 | } 32 | 33 | // Pipes returns an object that can list and get Pipes. 34 | func (s *pipeLister) Pipes(namespace string) PipeNamespaceLister { 35 | return pipeNamespaceLister{listers.NewNamespaced[*sshpiperv1beta1.Pipe](s.ResourceIndexer, namespace)} 36 | } 37 | 38 | // PipeNamespaceLister helps list and get Pipes. 39 | // All objects returned here must be treated as read-only. 40 | type PipeNamespaceLister interface { 41 | // List lists all Pipes in the indexer for a given namespace. 42 | // Objects returned here must be treated as read-only. 43 | List(selector labels.Selector) (ret []*sshpiperv1beta1.Pipe, err error) 44 | // Get retrieves the Pipe from the indexer for a given namespace and name. 45 | // Objects returned here must be treated as read-only. 46 | Get(name string) (*sshpiperv1beta1.Pipe, error) 47 | PipeNamespaceListerExpansion 48 | } 49 | 50 | // pipeNamespaceLister implements the PipeNamespaceLister 51 | // interface. 52 | type pipeNamespaceLister struct { 53 | listers.ResourceIndexer[*sshpiperv1beta1.Pipe] 54 | } 55 | -------------------------------------------------------------------------------- /plugin/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | piperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" 5 | sshpiper "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned" 6 | piperlister "github.com/tg123/sshpiper/plugin/kubernetes/generated/listers/sshpiper/v1beta1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/fields" 9 | "k8s.io/apimachinery/pkg/labels" 10 | "k8s.io/client-go/kubernetes" 11 | corev1 "k8s.io/client-go/kubernetes/typed/core/v1" 12 | "k8s.io/client-go/tools/cache" 13 | "k8s.io/client-go/tools/clientcmd" 14 | ) 15 | 16 | type plugin struct { 17 | k8sclient corev1.CoreV1Interface 18 | lister piperlister.PipeLister 19 | stop chan<- struct{} 20 | } 21 | 22 | func newKubernetesPlugin(allNamespaces bool, kubeConfigPath string) (*plugin, error) { 23 | loader := clientcmd.NewDefaultClientConfigLoadingRules() 24 | loader.ExplicitPath = kubeConfigPath 25 | kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 26 | loader, 27 | &clientcmd.ConfigOverrides{}, 28 | ) 29 | 30 | config, err := kubeConfig.ClientConfig() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | ns, _, err := kubeConfig.Namespace() 36 | if err != nil { 37 | return nil, err 38 | } 39 | if allNamespaces { 40 | ns = metav1.NamespaceAll 41 | } 42 | 43 | k8sclient, err := kubernetes.NewForConfig(config) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | piperclient, err := sshpiper.NewForConfig(config) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | listWatcher := cache.NewListWatchFromClient(piperclient.SshpiperV1beta1().RESTClient(), "pipes", ns, fields.Everything()) 54 | store := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}) 55 | lister := piperlister.NewPipeLister(store) 56 | reflector := cache.NewReflector(listWatcher, &piperv1beta1.Pipe{}, store, 0) 57 | 58 | stop := make(chan struct{}) 59 | go reflector.Run(stop) 60 | 61 | return &plugin{ 62 | k8sclient: k8sclient.CoreV1(), 63 | lister: lister, 64 | stop: stop, 65 | }, nil 66 | } 67 | 68 | func (p *plugin) Stop() { 69 | p.stop <- struct{}{} 70 | } 71 | 72 | func (p *plugin) list() ([]*piperv1beta1.Pipe, error) { 73 | return p.lister.List(labels.Everything()) 74 | } 75 | -------------------------------------------------------------------------------- /plugin/kubernetes/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/tg123/sshpiper/libplugin" 5 | "github.com/tg123/sshpiper/libplugin/skel" 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func main() { 10 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 11 | Name: "kubernetes", 12 | Usage: "sshpiperd kubernetes plugin", 13 | Flags: []cli.Flag{ 14 | &cli.BoolFlag{ 15 | Name: "all-namespaces", 16 | Usage: "To watch all namespaces in the cluster", 17 | EnvVars: []string{"SSHPIPERD_KUBERNETES_ALL_NAMESPACES"}, 18 | Required: false, 19 | }, 20 | &cli.StringFlag{ 21 | Name: "kubeconfig", 22 | Usage: "Path to kubeconfig file", 23 | EnvVars: []string{"SSHPIPERD_KUBERNETES_KUBECONFIG", "KUBECONFIG"}, 24 | Required: false, 25 | }, 26 | }, 27 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 28 | plugin, err := newKubernetesPlugin(c.Bool("all-namespaces"), c.String("kubeconfig")) 29 | if err != nil { 30 | return nil, err 31 | } 32 | skel := skel.NewSkelPlugin(plugin.listPipe) 33 | return skel.CreateConfig(), nil 34 | }, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /plugin/kubernetes/sample.yaml: -------------------------------------------------------------------------------- 1 | # sshpiper service 2 | --- 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: sshpiper 7 | spec: 8 | selector: 9 | app: sshpiper 10 | ports: 11 | - protocol: TCP 12 | port: 2222 13 | --- 14 | apiVersion: v1 15 | data: 16 | server_key: | 17 | LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNCWUhWV01lNzVDZ3Rzdm5rOWlTekJFU3hSdjdMb3U3K0tVbndmb3VnNzcxZ0FBQUpEQnArS0d3YWZpCmhnQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDQllIVldNZTc1Q2d0c3ZuazlpU3pCRVN4UnY3TG91NytLVW53Zm91Zzc3MWcKQUFBRUJKSDU3eTFaRTUxbVo2a2VsWUR0eDQ1ajBhZGdsUk5CY0pZOE94YTY4TEJWZ2RWWXg3dmtLQzJ5K2VUMkpMTUVSTApGRy9zdWk3djRwU2ZCK2k2RHZ2V0FBQUFEV0p2YkdsaGJrQjFZblZ1ZEhVPQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K 18 | kind: Secret 19 | metadata: 20 | name: sshpiper-server-key 21 | type: Opaque 22 | --- 23 | apiVersion: apps/v1 24 | kind: Deployment 25 | metadata: 26 | name: sshpiper-deployment 27 | labels: 28 | app: sshpiper 29 | spec: 30 | replicas: 1 31 | selector: 32 | matchLabels: 33 | app: sshpiper 34 | template: 35 | metadata: 36 | labels: 37 | app: sshpiper 38 | spec: 39 | serviceAccountName: sshpiper-account 40 | containers: 41 | - name: sshpiper 42 | imagePullPolicy: IfNotPresent 43 | image: farmer1992/sshpiperd:latest 44 | ports: 45 | - containerPort: 2222 46 | env: 47 | - name: PLUGIN 48 | value: "kubernetes" 49 | - name: SSHPIPERD_SERVER_KEY 50 | value: "/serverkey/ssh_host_ed25519_key" 51 | - name: SSHPIPERD_LOG_LEVEL 52 | value: "trace" 53 | volumeMounts: 54 | - name: sshpiper-server-key 55 | mountPath: "/serverkey/" 56 | readOnly: true 57 | volumes: 58 | - name: sshpiper-server-key 59 | secret: 60 | secretName: sshpiper-server-key 61 | items: 62 | - key: server_key 63 | path: ssh_host_ed25519_key 64 | --- 65 | apiVersion: rbac.authorization.k8s.io/v1 66 | kind: Role 67 | metadata: 68 | name: sshpiper-reader 69 | rules: 70 | - apiGroups: [""] 71 | resources: ["secrets"] 72 | verbs: ["get"] 73 | - apiGroups: ["sshpiper.com"] 74 | resources: ["pipes"] 75 | verbs: ["get", "list", "watch"] 76 | --- 77 | apiVersion: rbac.authorization.k8s.io/v1 78 | kind: RoleBinding 79 | metadata: 80 | name: read-sshpiper 81 | subjects: 82 | - kind: ServiceAccount 83 | name: sshpiper-account 84 | roleRef: 85 | kind: Role 86 | name: sshpiper-reader 87 | apiGroup: rbac.authorization.k8s.io 88 | --- 89 | apiVersion: v1 90 | kind: ServiceAccount 91 | metadata: 92 | name: sshpiper-account 93 | 94 | # pipe to a password based sshd 95 | --- 96 | apiVersion: sshpiper.com/v1beta1 97 | kind: Pipe 98 | metadata: 99 | name: pipe-password 100 | spec: 101 | from: 102 | - username: "password_simple" 103 | to: 104 | host: host-password:2222 105 | username: "user" 106 | ignore_hostkey: true 107 | --- 108 | apiVersion: v1 109 | kind: Service 110 | metadata: 111 | name: host-password 112 | spec: 113 | selector: 114 | app: host-password 115 | ports: 116 | - protocol: TCP 117 | port: 2222 118 | --- 119 | apiVersion: v1 120 | kind: Pod 121 | metadata: 122 | name: host-password 123 | labels: 124 | app: host-password 125 | spec: 126 | containers: 127 | - name: host-password 128 | imagePullPolicy: IfNotPresent 129 | image: lscr.io/linuxserver/openssh-server:latest 130 | ports: 131 | - containerPort: 2222 132 | env: 133 | - name: PASSWORD_ACCESS 134 | value: "true" 135 | - name: USER_PASSWORD 136 | value: "pass" 137 | - name: USER_NAME 138 | value: "user" 139 | 140 | 141 | # pipe to a key based sshd 142 | --- 143 | apiVersion: v1 144 | data: 145 | ssh-privatekey: | 146 | LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNEVVJreDk5dWF3MUtkZHJhWmNMcEI1a2ZNcld3dlV6MmZQT29BckxjcHo5UUFBQUpDK2owK1N2bzlQCmtnQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDRFVSa3g5OXVhdzFLZGRyYVpjTHBCNWtmTXJXd3ZVejJmUE9vQXJMY3B6OVEKQUFBRURjUWdkaDJ6MnIvNmJscTB6aUoxbDZzNklBWDhDKzlRSGZBSDkzMWNITk85UkdUSDMyNXJEVXAxMnRwbHd1a0htUgo4eXRiQzlUUFo4ODZnQ3N0eW5QMUFBQUFEV0p2YkdsaGJrQjFZblZ1ZEhVPQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K 147 | kind: Secret 148 | metadata: 149 | name: host-publickey-key 150 | type: kubernetes.io/ssh-auth 151 | --- 152 | apiVersion: sshpiper.com/v1beta1 153 | kind: Pipe 154 | metadata: 155 | name: pipe-publickey 156 | annotations: 157 | privatekey_field_name: ssh-privatekey # this is optional, default is ssh-privatekey 158 | spec: 159 | from: 160 | - username: ".*" # catch all 161 | username_regex_match: true 162 | authorized_keys_data: "c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSU5SR1RIMzI1ckRVcDEydHBsd3VrSG1SOHl0YkM5VFBaODg2Z0NzdHluUDEK" 163 | to: 164 | host: host-publickey:2222 165 | username: "user" 166 | private_key_secret: 167 | name: host-publickey-key 168 | ignore_hostkey: true 169 | --- 170 | apiVersion: v1 171 | kind: Service 172 | metadata: 173 | name: host-publickey 174 | spec: 175 | selector: 176 | app: host-publickey 177 | ports: 178 | - protocol: TCP 179 | port: 2222 180 | --- 181 | apiVersion: v1 182 | kind: Pod 183 | metadata: 184 | name: host-publickey 185 | labels: 186 | app: host-publickey 187 | spec: 188 | containers: 189 | - name: host-publickey 190 | image: lscr.io/linuxserver/openssh-server:latest 191 | imagePullPolicy: IfNotPresent 192 | ports: 193 | - containerPort: 2222 194 | env: 195 | - name: USER_NAME 196 | value: "user" 197 | - name: PUBLIC_KEY 198 | value: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRGTH325rDUp12tplwukHmR8ytbC9TPZ886gCstynP1" -------------------------------------------------------------------------------- /plugin/kubernetes/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import _ "k8s.io/code-generator" 7 | -------------------------------------------------------------------------------- /plugin/kubernetes/update-codegen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | go mod vendor 8 | 9 | SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}") 10 | REPO_ROOT=$(realpath "${SCRIPT_ROOT}/../../") 11 | CODEGEN_PKG=${REPO_ROOT}/vendor/k8s.io/code-generator 12 | THIS_PKG="github.com/tg123/sshpiper/plugin/kubernetes" 13 | 14 | source "${CODEGEN_PKG}/kube_codegen.sh" 15 | 16 | 17 | kube::codegen::gen_helpers \ 18 | --boilerplate /dev/null \ 19 | "${SCRIPT_ROOT}" 20 | 21 | kube::codegen::gen_register \ 22 | --boilerplate /dev/null \ 23 | "${SCRIPT_ROOT}" 24 | 25 | kube::codegen::gen_client \ 26 | --with-watch \ 27 | --output-dir "${SCRIPT_ROOT}/generated" \ 28 | --output-pkg "${THIS_PKG}/generated" \ 29 | --boilerplate /dev/null \ 30 | "${SCRIPT_ROOT}/apis" -------------------------------------------------------------------------------- /plugin/simplemath/README.md: -------------------------------------------------------------------------------- 1 | # simple math plugin for sshpiperd 2 | 3 | ## Usage 4 | 5 | Note: this is an additional challenge plugin. 🔒 you must use it with other routing plugins. 6 | 7 | ``` 8 | sshpiperd simplemath -- other-plugin --other-option 9 | ``` 10 | 11 | ### Demo 12 | 13 | ``` 14 | $ ssh 127.0.0.1 -l user -p 2222 15 | lets do math 16 | what is 7 + 9 = 17 | ``` -------------------------------------------------------------------------------- /plugin/simplemath/main.go: -------------------------------------------------------------------------------- 1 | //go:build full 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "math/rand" 8 | "strconv" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/tg123/sshpiper/libplugin" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func main() { 16 | 17 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 18 | Name: "simplemath", 19 | Usage: "sshpiperd simplemath plugin, do math before ssh login", 20 | CreateConfig: func(_ *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 21 | return &libplugin.SshPiperPluginConfig{ 22 | KeyboardInteractiveCallback: func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) { 23 | _, _ = client("", "lets do math", "", false) 24 | 25 | for { 26 | 27 | a := rand.Intn(10) 28 | b := rand.Intn(10) 29 | 30 | ans, err := client("", "", fmt.Sprintf("what is %v + %v = ", a, b), true) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | log.Printf("got ans = %v", ans) 36 | 37 | if ans == fmt.Sprintf("%v", a+b) { 38 | 39 | log.Printf("got ans = %v", ans) 40 | 41 | return &libplugin.Upstream{ 42 | Auth: libplugin.CreateNextPluginAuth(map[string]string{ 43 | "a": strconv.Itoa(a), 44 | "b": strconv.Itoa(b), 45 | "ans": ans, 46 | }), 47 | }, nil 48 | } 49 | } 50 | }, 51 | }, nil 52 | }, 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /plugin/username-router/README.md: -------------------------------------------------------------------------------- 1 | # username-router plugin for sshpiper 2 | 3 | Supports routing based on username. This plugin allows you to route connections to different targets based on the username provided during the SSH connection. 4 | The username format is `target+username`, where `target` is the target host and `username` is the username to use for that target. 5 | `target` can be an IP address or a hostname, and it can also include a port number in the format `target:port`. 6 | 7 | ## Usage 8 | 9 | ``` 10 | sshpiperd username-router 11 | ``` -------------------------------------------------------------------------------- /plugin/username-router/main.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/tg123/sshpiper/libplugin" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func parseTargetUser(raw string) (target string, username string, err error) { 15 | // Expect format: [target:port]+user 16 | parts := strings.SplitN(raw, "+", 2) 17 | if len(parts) != 2 { 18 | err = fmt.Errorf("invalid format (expected target:port+user)") 19 | return 20 | } 21 | 22 | target = parts[0] 23 | username = parts[1] 24 | return 25 | } 26 | 27 | func main() { 28 | 29 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 30 | Name: "username-router", 31 | Usage: "routing based on target inside username, format: 'target:port+realuser@sshpiper-host'", 32 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 33 | 34 | return &libplugin.SshPiperPluginConfig{ 35 | PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { 36 | 37 | address, user, err := parseTargetUser(conn.User()) 38 | if err != nil { 39 | return nil, fmt.Errorf("invalid username format %q: %w", conn.User(), err) 40 | } 41 | 42 | host, port, err := libplugin.SplitHostPortForSSH(address) 43 | if err != nil { 44 | return nil, fmt.Errorf("invalid target address %q: %w", address, err) 45 | } 46 | 47 | log.Info("routing to address ", address, " with user ", user) 48 | return &libplugin.Upstream{ 49 | UserName: user, 50 | Host: host, 51 | Port: int32(port), 52 | IgnoreHostKey: true, 53 | Auth: libplugin.CreatePasswordAuth(password), 54 | }, nil 55 | }, 56 | }, nil 57 | }, 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /plugin/workingdir/README.md: -------------------------------------------------------------------------------- 1 | # Working Directory plugin for sshpiperd 2 | 3 | `Working Dir` is a `/home`-like directory. 4 | sshpiperd read files from `workingdir/[username]/` to know upstream's configuration. 5 | 6 | e.g. 7 | 8 | ``` 9 | workingdir tree 10 | 11 | . 12 | ├── github 13 | │   └── sshpiper_upstream 14 | └── linode 15 | └── sshpiper_upstream 16 | ``` 17 | 18 | when `ssh sshpiper_host -l github`, 19 | sshpiper reads `workingdir/github/sshpiper_upstream` and the connect to the upstream. 20 | 21 | ## Usage 22 | 23 | ``` 24 | sshpiperd workingdir --root /var/sshpiper 25 | ``` 26 | 27 | ### options (allow supported read from environments) 28 | 29 | ``` 30 | --allow-baduser-name allow bad username (default: false) [$SSHPIPERD_WORKINGDIR_ALLOWBADUSERNAME] 31 | --no-check-perm disable 0400 checking (default: false) [$SSHPIPERD_WORKINGDIR_NOCHECKPERM] 32 | --no-password-auth disable password authentication and only use public key authentication (default: false) [$SSHPIPERD_WORKINGDIR_NOPASSWORD_AUTH] 33 | --root value path to root working directory (default: "/var/sshpiper") [$SSHPIPERD_WORKINGDIR_ROOT] 34 | --strict-hostkey upstream host public key must be in known_hosts file, otherwise drop the connection (default: false) [$SSHPIPERD_WORKINGDIR_STRICTHOSTKEY] 35 | ``` 36 | 37 | ## User files 38 | 39 | *These file MUST NOT be accessible to group or other. (chmod og-rwx filename)* 40 | 41 | * sshpiper_upstream 42 | 43 | * line starts with `#` are treated as comment 44 | * only the first not comment line will be parsed 45 | * if no port was given, 22 will be used as default 46 | * if `user@` was defined, username to upstream will be the mapped one 47 | 48 | ``` 49 | # comment 50 | [user@]upstream[:22] 51 | ``` 52 | 53 | ``` 54 | e.g. 55 | 56 | git@github.com 57 | 58 | google.com:12345 59 | 60 | ``` 61 | 62 | * authorized_keys 63 | 64 | OpenSSH format `authorized_keys` (see `~/.ssh/authorized_keys`). `downstream`'s public key must be in this file to get verified in order to use `id_rsa` to login to `upstream`. 65 | 66 | * id_rsa 67 | 68 | RSA key for upstream. 69 | 70 | * known_hosts 71 | 72 | when `--strict-hostkey` is set, upstream server's public key must present in known_hosts 73 | 74 | 75 | ## Recursive mode (--recursive-search) 76 | 77 | `--recursive-search` will search all sub directories of the `username` directory to find the `downstream` key in `authorized_keys` file. 78 | 79 | ``` 80 | ├── git 81 | │ ├── bitbucket 82 | │ │ └── sshpiper_upstream 83 | │ ├── github 84 | │ │ ├── authorized_keys 85 | │ │ ├── id_rsa 86 | │ │ └── sshpiper_upstream 87 | │ └── gitlab 88 | │ └── sshpiper_upstream 89 | ├── linode.... 90 | ``` 91 | 92 | ## TOTP 93 | 94 | `--check-totp` will check the TOTP 2FA before connecting to the upstream, compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`. 95 | 96 | the secret should be stored in `totp` file in working directory. 97 | for example: 98 | 99 | ``` 100 | /var/sshpiper/username/totp 101 | ``` 102 | 103 | ## FAQ 104 | 105 | * Q: Why sshpiperd still asks for password even I disabled password auth in upstream (different behavior from `v0`) 106 | A: You may want `--no-password-auth`, see 107 | * Q: What if I want to use a different key type for the SSH server instead of RSA? 108 | A: The [`workingdir` plugin hard-codes for `id_rsa` for simplicity](https://github.com/tg123/sshpiper/issues/554#issue-2959158335). Consider a different plugin like `yaml` if you need more flexibility. 109 | -------------------------------------------------------------------------------- /plugin/workingdir/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | 8 | "github.com/pquerna/otp/totp" 9 | "github.com/tg123/sshpiper/libplugin" 10 | "github.com/tg123/sshpiper/libplugin/skel" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func main() { 15 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 16 | Name: "workingdir", 17 | Usage: "sshpiperd workingdir plugin", 18 | Flags: []cli.Flag{ 19 | &cli.StringFlag{ 20 | Name: "root", 21 | Usage: "path to root working directory", 22 | Value: "/var/sshpiper", 23 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_ROOT"}, 24 | }, 25 | &cli.BoolFlag{ 26 | Name: "allow-baduser-name", 27 | Usage: "allow bad username", 28 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_ALLOWBADUSERNAME"}, 29 | }, 30 | &cli.BoolFlag{ 31 | Name: "no-check-perm", 32 | Usage: "disable 0400 checking", 33 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_NOCHECKPERM"}, 34 | }, 35 | &cli.BoolFlag{ 36 | Name: "strict-hostkey", 37 | Usage: "upstream host public key must be in known_hosts file, otherwise drop the connection", 38 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_STRICTHOSTKEY"}, 39 | }, 40 | &cli.BoolFlag{ 41 | Name: "no-password-auth", 42 | Usage: "disable password authentication and only use public key authentication", 43 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_NOPASSWORD_AUTH"}, 44 | }, 45 | &cli.BoolFlag{ 46 | Name: "recursive-search", 47 | Usage: "search subdirectories under user directory for upsteam", 48 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_RECURSIVESEARCH"}, 49 | }, 50 | &cli.BoolFlag{ 51 | Name: "check-totp", 52 | Usage: "check totp code for 2FA, totp file should be in user directory named `totp`", 53 | EnvVars: []string{"SSHPIPERD_WORKINGDIR_CHECKTOTP"}, 54 | }, 55 | }, 56 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 57 | fac := workdingdirFactory{ 58 | root: c.String("root"), 59 | allowBadUsername: c.Bool("allow-baduser-name"), 60 | noPasswordAuth: c.Bool("no-password-auth"), 61 | noCheckPerm: c.Bool("no-check-perm"), 62 | strictHostKey: c.Bool("strict-hostkey"), 63 | recursiveSearch: c.Bool("recursive-search"), 64 | } 65 | 66 | checktotp := c.Bool("check-totp") 67 | 68 | skel := skel.NewSkelPlugin(fac.listPipe) 69 | config := skel.CreateConfig() 70 | config.NextAuthMethodsCallback = func(conn libplugin.ConnMetadata) ([]string, error) { 71 | auth := []string{"publickey"} 72 | 73 | if !fac.noPasswordAuth { 74 | auth = append(auth, "password") 75 | } 76 | 77 | if checktotp { 78 | if conn.GetMeta("totp") != "checked" { 79 | auth = []string{"keyboard-interactive"} 80 | } 81 | } 82 | 83 | return auth, nil 84 | } 85 | 86 | config.KeyboardInteractiveCallback = func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) { 87 | user := conn.User() 88 | 89 | if !fac.allowBadUsername { 90 | if !isUsernameSecure(user) { 91 | return nil, fmt.Errorf("bad username: %s", user) 92 | } 93 | } 94 | 95 | w := &workingdir{ 96 | Path: path.Join(fac.root, conn.User()), 97 | NoCheckPerm: fac.noCheckPerm, 98 | } 99 | 100 | secret, err := w.Readfile("totp") 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | for { 106 | 107 | passcode, err := client("", "", "Authentication code:", true) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | if totp.Validate(passcode, strings.TrimSpace(string(secret))) { 113 | return &libplugin.Upstream{ 114 | Auth: libplugin.CreateRetryCurrentPluginAuth(map[string]string{ 115 | "totp": "checked", 116 | }), 117 | }, nil 118 | } 119 | 120 | _, _ = client("", "Wrong code, please try again", "", false) 121 | } 122 | } 123 | 124 | return config, nil 125 | }, 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /plugin/workingdir/skel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/tg123/sshpiper/libplugin" 11 | "github.com/tg123/sshpiper/libplugin/skel" 12 | ) 13 | 14 | type workdingdirFactory struct { 15 | root string 16 | allowBadUsername bool 17 | noPasswordAuth bool 18 | noCheckPerm bool 19 | strictHostKey bool 20 | recursiveSearch bool 21 | } 22 | 23 | type skelpipeWrapper struct { 24 | dir *workingdir 25 | 26 | host string 27 | username string 28 | } 29 | 30 | type skelpipeFromWrapper struct { 31 | skelpipeWrapper 32 | } 33 | 34 | type skelpipePasswordWrapper struct { 35 | skelpipeFromWrapper 36 | } 37 | 38 | type skelpipePublicKeyWrapper struct { 39 | skelpipeFromWrapper 40 | } 41 | 42 | type skelpipeToWrapper struct { 43 | skelpipeWrapper 44 | } 45 | 46 | type skelpipeToPasswordWrapper struct { 47 | skelpipeToWrapper 48 | } 49 | 50 | type skelpipeToPrivateKeyWrapper struct { 51 | skelpipeToWrapper 52 | } 53 | 54 | func (s *skelpipeWrapper) From() []skel.SkelPipeFrom { 55 | w := skelpipeFromWrapper{ 56 | skelpipeWrapper: *s, 57 | } 58 | 59 | if s.dir.Exists(userAuthorizedKeysFile) && s.dir.Exists(userKeyFile) { 60 | return []skel.SkelPipeFrom{&skelpipePublicKeyWrapper{ 61 | skelpipeFromWrapper: w, 62 | }} 63 | } else { 64 | return []skel.SkelPipeFrom{&skelpipePasswordWrapper{ 65 | skelpipeFromWrapper: w, 66 | }} 67 | } 68 | } 69 | 70 | func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { 71 | return s.username 72 | } 73 | 74 | func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { 75 | return s.host 76 | } 77 | 78 | func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { 79 | return !s.dir.Strict 80 | } 81 | 82 | func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { 83 | return s.dir.Readfile(userKnownHosts) 84 | } 85 | 86 | func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (skel.SkelPipeTo, error) { 87 | if s.dir.Exists(userKeyFile) { 88 | return &skelpipeToPrivateKeyWrapper{ 89 | skelpipeToWrapper: skelpipeToWrapper(*s), 90 | }, nil 91 | } 92 | 93 | return &skelpipeToPasswordWrapper{ 94 | skelpipeToWrapper: skelpipeToWrapper(*s), 95 | }, nil 96 | } 97 | 98 | func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { 99 | return true, nil // TODO support later 100 | } 101 | 102 | func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { 103 | return s.dir.Readfile(userAuthorizedKeysFile) 104 | } 105 | 106 | func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { 107 | return nil, nil // TODO support this 108 | } 109 | 110 | func (s *skelpipeToPrivateKeyWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { 111 | k, err := s.dir.Readfile(userKeyFile) 112 | if err != nil { 113 | return nil, nil, err 114 | } 115 | 116 | return k, nil, nil 117 | } 118 | 119 | func (s *skelpipeToPasswordWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { 120 | return nil, nil 121 | } 122 | 123 | func (wf *workdingdirFactory) listPipe(conn libplugin.ConnMetadata) ([]skel.SkelPipe, error) { 124 | user := conn.User() 125 | 126 | if !wf.allowBadUsername { 127 | if !isUsernameSecure(user) { 128 | return nil, fmt.Errorf("bad username: %s", user) 129 | } 130 | } 131 | 132 | var pipes []skel.SkelPipe 133 | userdir := path.Join(wf.root, conn.User()) 134 | 135 | _ = filepath.Walk(userdir, func(path string, info os.FileInfo, err error) (stop error) { 136 | log.Infof("search upstreams in path: %v", path) 137 | if err != nil { 138 | log.Infof("error walking path: %v", err) 139 | return 140 | } 141 | 142 | if !info.IsDir() { 143 | return 144 | } 145 | 146 | if !wf.recursiveSearch { 147 | stop = fmt.Errorf("stop") 148 | } 149 | 150 | w := &workingdir{ 151 | Path: path, 152 | NoCheckPerm: wf.noCheckPerm, 153 | Strict: wf.strictHostKey, 154 | } 155 | 156 | data, err := w.Readfile(userUpstreamFile) 157 | if err != nil { 158 | log.Infof("error reading upstream file: %v in %v", err, w.Path) 159 | return 160 | } 161 | 162 | host, user, err := parseUpstreamFile(string(data)) 163 | if err != nil { 164 | log.Infof("ignore upstream folder %v due to: %v", w.Path, err) 165 | return 166 | } 167 | 168 | pipes = append(pipes, &skelpipeWrapper{ 169 | dir: w, 170 | host: host, 171 | username: user, 172 | }) 173 | 174 | return 175 | }) 176 | 177 | return pipes, nil 178 | } 179 | -------------------------------------------------------------------------------- /plugin/workingdir/workingdir.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "path" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/tg123/sshpiper/libplugin" 12 | ) 13 | 14 | type workingdir struct { 15 | Path string 16 | NoCheckPerm bool 17 | Strict bool 18 | } 19 | 20 | // Base username validation on Debians default: https://sources.debian.net/src/adduser/3.113%2Bnmu3/adduser.conf/#L85 21 | // -> NAME_REGEX="^[a-z][-a-z0-9_]*\$" 22 | // The length is limited to 32 characters. See man 8 useradd: https://linux.die.net/man/8/useradd 23 | var usernameRule *regexp.Regexp = regexp.MustCompile("^[a-z_][-a-z0-9_]{0,31}$") 24 | 25 | const ( 26 | userAuthorizedKeysFile = "authorized_keys" 27 | userKeyFile = "id_rsa" 28 | userUpstreamFile = "sshpiper_upstream" 29 | userKnownHosts = "known_hosts" 30 | ) 31 | 32 | func isUsernameSecure(user string) bool { 33 | return usernameRule.MatchString(user) 34 | } 35 | 36 | func (w *workingdir) checkPerm(file string) error { 37 | filename := path.Join(w.Path, file) 38 | f, err := os.Open(filename) 39 | if err != nil { 40 | return err 41 | } 42 | defer f.Close() 43 | 44 | fi, err := f.Stat() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if w.NoCheckPerm { 50 | return nil 51 | } 52 | 53 | if fi.Mode().Perm()&0o077 != 0 { 54 | return fmt.Errorf("%v's perm is too open", filename) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (w *workingdir) fullpath(file string) string { 61 | return path.Join(w.Path, file) 62 | } 63 | 64 | func (w *workingdir) Readfile(file string) ([]byte, error) { 65 | if err := w.checkPerm(file); err != nil { 66 | return nil, err 67 | } 68 | 69 | return os.ReadFile(w.fullpath(file)) 70 | } 71 | 72 | func (w *workingdir) Exists(file string) bool { 73 | info, err := os.Stat(w.fullpath(file)) 74 | if os.IsNotExist(err) { 75 | return false 76 | } 77 | 78 | return !info.IsDir() 79 | } 80 | 81 | // TODO refactor this 82 | func parseUpstreamFile(data string) (host string, user string, err error) { 83 | r := bufio.NewReader(strings.NewReader(data)) 84 | for { 85 | host, err = r.ReadString('\n') 86 | if err != nil { 87 | break 88 | } 89 | 90 | host = strings.TrimSpace(host) 91 | 92 | if host != "" && host[0] != '#' { 93 | break 94 | } 95 | } 96 | 97 | t := strings.SplitN(host, "@", 2) 98 | 99 | if len(t) > 1 { 100 | user = t[0] 101 | host = t[1] 102 | } 103 | 104 | _, _, err = libplugin.SplitHostPortForSSH(host) 105 | 106 | return 107 | } 108 | -------------------------------------------------------------------------------- /plugin/yaml/main.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/tg123/sshpiper/libplugin" 7 | "github.com/tg123/sshpiper/libplugin/skel" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func main() { 12 | plugin := newYamlPlugin() 13 | 14 | libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ 15 | Name: "yaml", 16 | Usage: "sshpiperd yaml plugin", 17 | Flags: []cli.Flag{ 18 | &cli.StringSliceFlag{ 19 | Name: "config", 20 | Usage: "path to yaml config files, can be globs as well", 21 | Required: true, 22 | EnvVars: []string{"SSHPIPERD_YAML_CONFIG"}, 23 | Destination: &plugin.FileGlobs, 24 | }, 25 | &cli.BoolFlag{ 26 | Name: "no-check-perm", 27 | Usage: "disable 0400 checking", 28 | EnvVars: []string{"SSHPIPERD_YAML_NOCHECKPERM"}, 29 | Destination: &plugin.NoCheckPerm, 30 | }, 31 | }, 32 | CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { 33 | skel := skel.NewSkelPlugin(plugin.listPipe) 34 | return skel.CreateConfig(), nil 35 | }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /plugin/yaml/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$ref": "#/definitions/sshpiperd", 4 | "definitions": { 5 | "sshpiperd": { 6 | "type": "object", 7 | "additionalProperties": false, 8 | "properties": { 9 | "version": { 10 | "type": "string" 11 | }, 12 | "pipes": { 13 | "type": "array", 14 | "items": { 15 | "$ref": "#/definitions/pipe" 16 | } 17 | } 18 | }, 19 | "required": [ 20 | "pipes", 21 | "version" 22 | ] 23 | }, 24 | "pipe": { 25 | "type": "object", 26 | "additionalProperties": false, 27 | "properties": { 28 | "from": { 29 | "type": "array", 30 | "items": { 31 | "$ref": "#/definitions/from" 32 | } 33 | }, 34 | "to": { 35 | "$ref": "#/definitions/to" 36 | } 37 | }, 38 | "required": [ 39 | "from", 40 | "to" 41 | ] 42 | }, 43 | "from": { 44 | "type": "object", 45 | "additionalProperties": false, 46 | "properties": { 47 | "username": { 48 | "type": "string" 49 | }, 50 | "username_regex_match": { 51 | "type": "boolean" 52 | }, 53 | "groupname": { 54 | "type": "string" 55 | }, 56 | "authorized_keys": { 57 | "oneOf": [ 58 | { 59 | "type": "array", 60 | "items": { 61 | "type": "string" 62 | } 63 | }, 64 | { 65 | "type": "string" 66 | } 67 | ] 68 | }, 69 | "authorized_keys_data": { 70 | "oneOf": [ 71 | { 72 | "type": "array", 73 | "items": { 74 | "type": "string" 75 | } 76 | }, 77 | { 78 | "type": "string" 79 | } 80 | ] 81 | }, 82 | "trusted_user_ca_keys": { 83 | "oneOf": [ 84 | { 85 | "type": "array", 86 | "items": { 87 | "type": "string" 88 | } 89 | }, 90 | { 91 | "type": "string" 92 | } 93 | ] 94 | }, 95 | "trusted_user_ca_keys_data": { 96 | "oneOf": [ 97 | { 98 | "type": "array", 99 | "items": { 100 | "type": "string" 101 | } 102 | }, 103 | { 104 | "type": "string" 105 | } 106 | ] 107 | } 108 | }, 109 | "required": [ 110 | "username" 111 | ] 112 | }, 113 | "to": { 114 | "type": "object", 115 | "additionalProperties": false, 116 | "properties": { 117 | "host": { 118 | "type": "string" 119 | }, 120 | "username": { 121 | "type": "string" 122 | }, 123 | "ignore_hostkey": { 124 | "type": "boolean" 125 | }, 126 | "password": { 127 | "type": "string" 128 | }, 129 | "private_key": { 130 | "type": "string" 131 | }, 132 | "private_key_data": { 133 | "type": "string" 134 | }, 135 | "known_hosts": { 136 | "oneOf": [ 137 | { 138 | "type": "array", 139 | "items": { 140 | "type": "string" 141 | } 142 | }, 143 | { 144 | "type": "string" 145 | } 146 | ] 147 | }, 148 | "known_hosts_data": { 149 | "oneOf": [ 150 | { 151 | "type": "array", 152 | "items": { 153 | "type": "string" 154 | } 155 | }, 156 | { 157 | "type": "string" 158 | } 159 | ] 160 | } 161 | }, 162 | "required": [ 163 | "host" 164 | ] 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /plugin/yaml/yaml.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "encoding/base64" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/urfave/cli/v2" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type yamlPipeFrom struct { 17 | Username string `yaml:"username,omitempty"` 18 | Groupname string `yaml:"groupname,omitempty"` 19 | UsernameRegexMatch bool `yaml:"username_regex_match,omitempty"` 20 | AuthorizedKeys listOrString `yaml:"authorized_keys,omitempty"` 21 | AuthorizedKeysData listOrString `yaml:"authorized_keys_data,omitempty"` 22 | TrustedUserCAKeys listOrString `yaml:"trusted_user_ca_keys,omitempty"` 23 | TrustedUserCAKeysData listOrString `yaml:"trusted_user_ca_keys_data,omitempty"` 24 | } 25 | 26 | func (f yamlPipeFrom) SupportPublicKey() bool { 27 | return f.AuthorizedKeys.Any() || f.AuthorizedKeysData.Any() || f.TrustedUserCAKeys.Any() || f.TrustedUserCAKeysData.Any() 28 | } 29 | 30 | type yamlPipeTo struct { 31 | Username string `yaml:"username,omitempty"` 32 | Host string `yaml:"host"` 33 | Password string `yaml:"password,omitempty"` 34 | PrivateKey string `yaml:"private_key,omitempty"` 35 | PrivateKeyData string `yaml:"private_key_data,omitempty"` 36 | KnownHosts listOrString `yaml:"known_hosts,omitempty"` 37 | KnownHostsData listOrString `yaml:"known_hosts_data,omitempty"` 38 | IgnoreHostkey bool `yaml:"ignore_hostkey,omitempty"` 39 | } 40 | 41 | type listOrString struct { 42 | List []string 43 | Str string 44 | } 45 | 46 | func (l *listOrString) Any() bool { 47 | return len(l.List) > 0 || l.Str != "" 48 | } 49 | 50 | func (l *listOrString) Combine() []string { 51 | if l.Str != "" { 52 | return append(l.List, l.Str) 53 | } 54 | return l.List 55 | } 56 | 57 | func (l *listOrString) UnmarshalYAML(value *yaml.Node) error { 58 | // Try to unmarshal as a list 59 | var list []string 60 | if err := value.Decode(&list); err == nil { 61 | l.List = list 62 | return nil 63 | } 64 | // Try to unmarshal as a string 65 | var str string 66 | if err := value.Decode(&str); err == nil { 67 | l.Str = str 68 | return nil 69 | } 70 | return fmt.Errorf("failed to unmarshal OneOfType") 71 | } 72 | 73 | type yamlPipe struct { 74 | From []yamlPipeFrom `yaml:"from,flow"` 75 | To yamlPipeTo `yaml:"to,flow"` 76 | } 77 | 78 | type piperConfig struct { 79 | Version string `yaml:"version"` 80 | Pipes []yamlPipe `yaml:"pipes,flow"` 81 | 82 | filename string 83 | } 84 | 85 | type plugin struct { 86 | FileGlobs cli.StringSlice 87 | NoCheckPerm bool 88 | } 89 | 90 | func newYamlPlugin() *plugin { 91 | return &plugin{} 92 | } 93 | 94 | func (p *plugin) checkPerm(filename string) error { 95 | f, err := os.Open(filename) 96 | if err != nil { 97 | return err 98 | } 99 | defer f.Close() 100 | 101 | fi, err := f.Stat() 102 | if err != nil { 103 | return err 104 | } 105 | 106 | if p.NoCheckPerm { 107 | return nil 108 | } 109 | 110 | if fi.Mode().Perm()&0077 != 0 { 111 | return fmt.Errorf("%v's perm is too open", filename) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func (p *plugin) loadConfig() ([]piperConfig, error) { 118 | var allconfig []piperConfig 119 | 120 | for _, fg := range p.FileGlobs.Value() { 121 | files, err := filepath.Glob(fg) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | for _, file := range files { 127 | 128 | if err := p.checkPerm(file); err != nil { 129 | return nil, err 130 | } 131 | 132 | configbyte, err := os.ReadFile(file) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | var config piperConfig 138 | 139 | err = yaml.Unmarshal(configbyte, &config) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | config.filename = file 145 | 146 | allconfig = append(allconfig, config) 147 | 148 | } 149 | } 150 | 151 | return allconfig, nil 152 | } 153 | 154 | func (p *piperConfig) loadFileOrDecode(file string, base64data string, vars map[string]string) ([]byte, error) { 155 | if file != "" { 156 | 157 | file = os.Expand(file, func(placeholderName string) string { 158 | v, ok := vars[placeholderName] 159 | if ok { 160 | return v 161 | } 162 | 163 | return os.Getenv(placeholderName) 164 | }) 165 | 166 | if !filepath.IsAbs(file) { 167 | file = filepath.Join(filepath.Dir(p.filename), file) 168 | } 169 | 170 | return os.ReadFile(file) 171 | } 172 | 173 | if base64data != "" { 174 | return base64.StdEncoding.DecodeString(base64data) 175 | } 176 | 177 | return nil, nil 178 | } 179 | 180 | func (p *piperConfig) loadFileOrDecodeMany(files listOrString, base64data listOrString, vars map[string]string) ([]byte, error) { 181 | var byteSlices [][]byte 182 | 183 | for _, file := range files.Combine() { 184 | data, err := p.loadFileOrDecode(file, "", vars) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | if data != nil { 190 | byteSlices = append(byteSlices, data) 191 | } 192 | } 193 | 194 | for _, data := range base64data.Combine() { 195 | decoded, err := base64.StdEncoding.DecodeString(data) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | if decoded != nil { 201 | byteSlices = append(byteSlices, decoded) 202 | } 203 | } 204 | 205 | return bytes.Join(byteSlices, []byte("\n")), nil 206 | } 207 | -------------------------------------------------------------------------------- /plugin/yaml/yaml_test.go: -------------------------------------------------------------------------------- 1 | //go:build full || e2e 2 | 3 | package main 4 | 5 | import ( 6 | "testing" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const yamlConfigTemplate = ` 12 | version: "1.0" 13 | pipes: 14 | - from: 15 | - username: "password_simple" 16 | to: 17 | host: host-password:2222 18 | username: "user" 19 | ignore_hostkey: true 20 | - from: 21 | - username: "password_.*_regex" 22 | username_regex_match: true 23 | to: 24 | host: host-password:2222 25 | username: "user" 26 | known_hosts_data: 27 | - fDF8RjRwTmVveUZHVEVHcEIyZ3A4RGE0WlE4TGNVPXxycVZYNU0rWTJoS0dteFphcVFBb0syRHp1TEE9IHNzaC1lZDI1NTE5IEFBQUFDM056YUMxbFpESTFOVEU1QUFBQUlPTXFxbmtWenJtMFNkRzZVT29xS0xzYWJnSDVDOW9rV2kwZGgybDlHS0psCg== 28 | - fDF8VzRpUUd0VFVyREJwSjM3RnFuOWRwcEdVRE5jPXxEZWFna2RwVHpZZDExdDhYWXlORnlhZmROZ2c9IHNzaC1lZDI1NTE5IEFBQUFDM056YUMxbFpESTFOVEU1QUFBQUlBZnVDSEtWVGpxdXh2dDZDTTZ0ZEc0U0xwMUJ0bi9uT2VISEU1VU96UmRmCg== 29 | - from: 30 | - username: "publickey_simple" 31 | authorized_keys: /tmp/auth_keys 32 | to: 33 | host: host-publickey:2222 34 | username: "user" 35 | private_key: /tmp/private_key 36 | known_hosts_data: fDF8RjRwTmVveUZHVEVHcEIyZ3A4RGE0WlE4TGNVPXxycVZYNU0rWTJoS0dteFphcVFBb0syRHp1TEE9IHNzaC1lZDI1NTE5IEFBQUFDM056YUMxbFpESTFOVEU1QUFBQUlPTXFxbmtWenJtMFNkRzZVT29xS0xzYWJnSDVDOW9rV2kwZGgybDlHS0psCg== 37 | - from: 38 | - username: ".*" 39 | username_regex_match: true 40 | authorized_keys: 41 | - /tmp/private_key1 42 | - /tmp/private_key2 43 | to: 44 | host: host-publickey:2222 45 | username: "user" 46 | ignore_hostkey: true 47 | private_key: /tmp/private_key 48 | ` 49 | 50 | func TestYamlDecode(t *testing.T) { 51 | var config piperConfig 52 | 53 | err := yaml.Unmarshal([]byte(yamlConfigTemplate), &config) 54 | if err != nil { 55 | t.Fatalf("Failed to unmarshal yaml: %v", err) 56 | } 57 | 58 | } 59 | --------------------------------------------------------------------------------