├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------