├── .github └── workflows │ ├── ghcr.yaml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── install.sh └── main.go /.github/workflows/ghcr.yaml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | # Defines two custom environment variables for the workflow. These are used for the Container 9 | # registry domain, and a name for the Docker image that this workflow builds. 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 19 | permissions: 20 | contents: read 21 | packages: write 22 | attestations: write 23 | id-token: write 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Log in to the Container registry 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ${{ env.REGISTRY }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) 37 | # to extract tags and labels that will be applied to the specified image. The `id` "meta" 38 | # allows the output of this step to be referenced in a subsequent step. The `images` value 39 | # provides the base name for the tags and labels. 40 | - name: Extract metadata (tags, labels) for Docker 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 45 | 46 | - name: Build and push Docker image 47 | id: push 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 53 | labels: ${{ steps.meta.outputs.labels }} 54 | build-args: | 55 | APP_VERSION=${{ github.ref_name }} 56 | 57 | # This step generates an artifact attestation for the image, which is an unforgeable 58 | # statement about where and how it was built. It increases supply chain security for 59 | # people who consume the image. For more information, see 60 | # [AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds). 61 | - name: Generate artifact attestation 62 | uses: actions/attest-build-provenance@v2 63 | with: 64 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 65 | subject-digest: ${{ steps.push.outputs.digest }} 66 | push-to-registry: true 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | env: 9 | APP_NAME: ${{ github.event.repository.name }} 10 | 11 | jobs: 12 | build_assets: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | packages: write 17 | strategy: 18 | matrix: 19 | goos: [linux, darwin, windows] 20 | goarch: [amd64, arm64] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: '1.24' 29 | 30 | - name: Build binary 31 | run: | 32 | mkdir -p dist 33 | GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ 34 | go build -ldflags="-X 'main.Version=${{ github.ref_name }}'" \ 35 | -o dist/${{ env.APP_NAME }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} 36 | 37 | - name: Upload binaries 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: ${{ env.APP_NAME }}-binaries-${{ matrix.goos }}-${{ matrix.goarch }} 41 | path: dist/ 42 | 43 | create_release: 44 | needs: build_assets 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: write 48 | packages: write 49 | outputs: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v4 54 | 55 | - name: Create GitHub Release 56 | id: create_release 57 | uses: softprops/action-gh-release@v2 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | name: Release ${{ github.ref_name }} 62 | tag_name: ${{ github.ref }} 63 | draft: false 64 | prerelease: false 65 | 66 | upload_assets: 67 | needs: [build_assets, create_release] 68 | runs-on: ubuntu-latest 69 | permissions: 70 | contents: write 71 | packages: write 72 | strategy: 73 | matrix: 74 | goos: [linux, darwin, windows] 75 | goarch: [amd64, arm64] 76 | steps: 77 | - name: Checkout repository 78 | uses: actions/checkout@v4 79 | 80 | - name: Download binaries 81 | uses: actions/download-artifact@v4 82 | with: 83 | merge-multiple: true 84 | pattern: ${{ env.APP_NAME }}-binaries-* 85 | path: dist/ 86 | 87 | - name: Upload Release Asset 88 | uses: actions/upload-release-asset@v1 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | with: 92 | upload_url: ${{ needs.create_release.outputs.upload_url }} 93 | asset_path: dist/${{ env.APP_NAME }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} 94 | asset_name: ${{ env.APP_NAME }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} 95 | asset_content_type: application/octet-stream 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS builder 2 | LABEL maintainer="Tomas Karela Prochazka " 3 | WORKDIR /app 4 | ENV CGO_ENABLED=0 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | COPY . . 8 | RUN go build -ldflags="-X 'main.Version=$APP_VERSION'" . 9 | 10 | FROM gcr.io/distroless/static 11 | LABEL maintainer="Tomas Karela Prochazka " 12 | COPY --from=builder /app/sshrelay /bin/sshrelay 13 | CMD ["/bin/sshrelay"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Tomáš Karela Procházka (Dataddo) 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSH Relay 2 | 3 | This is a simple SSH server that accepts just port forwarding requests from any 4 | SSH client, and forwards the connection to a target server. It is useful when 5 | you want to expose a server that is behind a firewall or NAT, and don't want to 6 | run a full OpenSSH server, with all the security implications that may come with 7 | it. 8 | 9 | ## Install 10 | 11 | If you have Go installed, you can install this with the following command. 12 | 13 | ``` 14 | go install github.com/prochac/sshrelay@latest 15 | ``` 16 | 17 | Without Go, you can download the binary from 18 | the [releases page](https://github.com/prochac/sshrelay/releases). 19 | 20 | Or on Linux, you can install it with the provided installation script, that will 21 | install it as a systemd service. 22 | 23 | ``` 24 | sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/prochac/sshrelay/master/install.sh)" 25 | ``` 26 | 27 | ## Usage 28 | 29 | If you want to set up users, you must use the `--public-key` or 30 | `--public-key-inline` to specify the public key file for the user. The number of 31 | `--user` flags and `--public-key` or `--public-key-inline` flags must be the 32 | same. 33 | 34 | ``` 35 | Usage: 36 | sshrelay [flags] [args] 37 | 38 | Flags: 39 | --generate-keys Generate host keys if not exist 40 | -h, --help help for sshrelay 41 | --host string Hostname or IP address to listen on (default "0.0.0.0") 42 | --host-key strings Host key file (default [/etc/ssh/ssh_host_rsa_key,/etc/ssh/ssh_host_ecdsa_key,/etc/ssh/ssh_host_ed25519_key]) 43 | --port uint Port to listen on (default 22) 44 | --public-key strings Path to public key file for user 45 | --public-key-inline strings Public key file for user, directly as string 46 | --user strings Allowed user 47 | ``` 48 | 49 | ### systemd 50 | 51 | If you want to use this with Systemd as a service, use this simple template. 52 | 53 | ```unit file (systemd) 54 | [Unit] 55 | Description=SSH Relay Service 56 | After=network.target 57 | 58 | [Service] 59 | Type=simple 60 | ExecStart=/usr/bin/sshrelay \ 61 | --host-key /etc/ssh/ssh_host_rsa_key \ 62 | --host-key /etc/ssh/ssh_host_ed25519_key \ 63 | --port 2222 \ 64 | --user bob \ 65 | --public-key /home/bob/.ssh/id_rsa.pub 66 | KillMode=process 67 | Restart=always 68 | SyslogIdentifier=sshrelay 69 | 70 | [Install] 71 | WantedBy=multi-user.target 72 | ``` 73 | 74 | ## Client usage 75 | 76 | Do as you would do with any other SSH server you want to use local port 77 | forwarding with. 78 | 79 | ex. forward `neverssl.com` to your local port 80 | 81 | ``` 82 | ssh -N -p {port} {user}@{host} -L 127.0.0.1:8080:neverssl.com:80 83 | ``` 84 | 85 | ## Contributing 86 | 87 | Fixes and improvements are welcome. 88 | If you want to add a feature, please open an issue first, so we can discuss it. 89 | The Issues with `help wanted` label are a good place to start contributing. 90 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.dataddo.com/sshrelay 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/bep/simplecobra v0.6.0 7 | github.com/gliderlabs/ssh v0.3.8 8 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a 9 | golang.org/x/crypto v0.36.0 10 | ) 11 | 12 | require ( 13 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/spf13/cobra v1.8.1 // indirect 16 | github.com/spf13/pflag v1.0.5 // indirect 17 | golang.org/x/sys v0.31.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/bep/simplecobra v0.6.0 h1:PpY/0PvYp6jt4OC/9SGoNPi6HzvpYzu8IPogVV6Xk90= 4 | github.com/bep/simplecobra v0.6.0/go.mod h1:q0ecBAefJZYpzgkbPbQ901hzA98g3ZvCZWZRhzNtB5o= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 7 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 8 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 9 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 10 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 11 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 15 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 16 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 17 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 18 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= 19 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= 20 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 21 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 22 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 23 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 24 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 25 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 26 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 27 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 28 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 29 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 30 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 31 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 32 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | # parse flag -y 4 | while getopts ":y" opt; do 5 | case ${opt} in 6 | y) 7 | yes_all=true 8 | ;; 9 | \?) 10 | ;; 11 | esac 12 | done 13 | 14 | confirm() { 15 | printf "%s [y/N] " "${1:-Are you sure?}" 16 | read -r response 17 | case "$response" in 18 | [yY][eE][sS] | [yY]) 19 | true 20 | ;; 21 | *) 22 | false 23 | ;; 24 | esac 25 | } 26 | 27 | # input with default 28 | printf "Enter the path to install sshrelay [/usr/local/bin]: " 29 | read -r install_path 30 | install_path=${install_path:-/usr/local/bin} 31 | 32 | [ -z "$yes_all" ] && (confirm "Do you want to install sshrelay to ${install_path}?" || exit 0) 33 | 34 | # check if the path exists 35 | if [ ! -d "${install_path}" ]; then 36 | [ -z "$yes_all" ] && (confirm "The path ${install_path} does not exist. Do you want to create it?" || exit 0) 37 | mkdir -p "${install_path}" 38 | fi 39 | 40 | if [ ! -w "${install_path}" ]; then 41 | printf "You don't have write permission to %s. Try with sudo\n" "${install_path}" 42 | exit 1 43 | fi 44 | if [ -f "${install_path}/sshrelay" ]; then 45 | [ -z "$yes_all" ] && (confirm "sshrelay is already installed. Do you want to overwrite it?" || exit 0) 46 | fi 47 | 48 | 49 | binary="sshrelay-" 50 | case "$(uname -s)" in 51 | Darwin) 52 | binary="${binary}darwin" 53 | ;; 54 | Linux) 55 | binary="${binary}linux" 56 | ;; 57 | CYGWIN* | MINGW32* | MSYS* | MINGW*) 58 | binary="${binary}windows" 59 | ;; 60 | *) 61 | printf "Unknown OS: %s, go fix yourself\n" "$(uname -s)" 62 | exit 1 63 | ;; 64 | esac 65 | 66 | case "$(uname -m)" in 67 | x86_64 | amd64) 68 | binary="${binary}-amd64" 69 | ;; 70 | aarch64 | arm*) 71 | binary="${binary}-arm64" 72 | ;; 73 | *) 74 | printf "Unknown or unsupported architecture: %s\n" "$(uname -m)" 75 | exit 1 76 | ;; 77 | esac 78 | 79 | 80 | 81 | query=$(cat < /etc/systemd/system/sshrelay.service 127 | systemctl daemon-reload 128 | 129 | echo "Successfully created systemd service sshrelay" 130 | echo "Please edit /etc/systemd/system/sshrelay.service to add access to some users" 131 | echo "Then don't forget to run 'systemctl daemon-reload' to reload the service" 132 | 133 | [ -z "$yes_all" ] && (confirm "Do you want to start the sshrelay service?" || exit 0) 134 | systemctl enable sshrelay 135 | systemctl start sshrelay 136 | systemctl status sshrelay 137 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/x509" 11 | "encoding/pem" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "log" 16 | "net" 17 | "os" 18 | "os/signal" 19 | "path/filepath" 20 | "runtime/debug" 21 | "strconv" 22 | "syscall" 23 | 24 | "github.com/bep/simplecobra" 25 | "github.com/gliderlabs/ssh" 26 | "github.com/mikesmitty/edkey" 27 | gossh "golang.org/x/crypto/ssh" 28 | ) 29 | 30 | var Version = "" 31 | var GoVersion = "" 32 | var BuildInfo debug.BuildInfo 33 | 34 | func init() { 35 | i, ok := debug.ReadBuildInfo() 36 | if !ok { 37 | return 38 | } 39 | BuildInfo = *i 40 | GoVersion = i.GoVersion 41 | // in a case of `go install`, and not pre-built binary 42 | if Version == "" { 43 | Version = i.Main.Version 44 | } 45 | } 46 | 47 | type rootCmd struct { 48 | flags cmdFlags 49 | cfg config 50 | } 51 | 52 | func (r *rootCmd) Name() string { return "sshrelay" } 53 | 54 | type versionCommander struct { 55 | verbose bool 56 | } 57 | 58 | func (v *versionCommander) Name() string { return "version" } 59 | func (v *versionCommander) Run(context.Context, *simplecobra.Commandeer, []string) error { 60 | fmt.Printf("SSHRelay %s (%s)\n", Version, GoVersion) 61 | if v.verbose { 62 | fmt.Print(BuildInfo.String()) 63 | } 64 | return nil 65 | } 66 | 67 | func (v *versionCommander) Init(c *simplecobra.Commandeer) error { 68 | flags := c.CobraCommand.Flags() 69 | flags.BoolVarP(&v.verbose, "verbose", "v", false, "Print verbose version information") 70 | return nil 71 | } 72 | func (v *versionCommander) PreRun(_, _ *simplecobra.Commandeer) error { return nil } 73 | func (v *versionCommander) Commands() []simplecobra.Commander { return nil } 74 | 75 | func (r *rootCmd) Commands() []simplecobra.Commander { 76 | return []simplecobra.Commander{ 77 | &versionCommander{}, 78 | } 79 | } 80 | 81 | func main() { 82 | ex, err := simplecobra.New(&rootCmd{}) 83 | if err != nil { 84 | fmt.Println(err) 85 | os.Exit(2) 86 | } 87 | if c, err := ex.Execute(context.Background(), os.Args[1:]); err != nil { 88 | if simplecobra.IsCommandError(err) { 89 | _ = c.CobraCommand.Help() 90 | fmt.Println() 91 | fmt.Println(err) 92 | os.Exit(2) 93 | } 94 | log.Fatal(err) 95 | } 96 | } 97 | 98 | func (r *rootCmd) Run(ctx context.Context, _ *simplecobra.Commandeer, _ []string) error { 99 | srv := ssh.Server{ 100 | Addr: r.cfg.addr, 101 | Handler: func(s ssh.Session) { 102 | _, _ = io.WriteString(s, "Only port forwarding available...\n") 103 | _, _ = io.WriteString(s, "Use '-N' flag to not start terminal session\n") 104 | }, 105 | HostSigners: r.cfg.signers, 106 | Version: "SSHRelay_" + Version, 107 | BannerHandler: func(ctx ssh.Context) string { 108 | return "" + 109 | "###################################################################\n" + 110 | "# #\n" + 111 | "# 888888ba dP dP dP #\n" + 112 | "# 88 `8b 88 88 88 #\n" + 113 | "# 88 88 .d8888b. d8888P .d8888b. .d888b88 .d888b88 .d8888b. #\n" + 114 | "# 88 88 88' `88 88 88' `88 88' `88 88' `88 88' `88 #\n" + 115 | "# 88 .8P 88. .88 88 88. .88 88. .88 88. .88 88. .88 #\n" + 116 | "# 8888888P `88888P8 dP `88888P8 `88888P8 `88888P8 `88888P' #\n" + 117 | "# __________ __ __ ____ __ #\n" + 118 | "# / ___/ ___// / / / / __ \\___ / /___ ___ __ #\n" + 119 | "# \\__ \\\\__ \\/ /_/ / / /_/ / _ \\/ / __ `/ / / / #\n" + 120 | "# ___/ /__/ / __ / / _, _/ __/ / /_/ / /_/ / #\n" + 121 | "# /____/____/_/ /_/ /_/ |_|\\___/_/\\__,_/\\__, / #\n" + 122 | "# /____/ #\n" + 123 | "###################################################################\n" 124 | }, 125 | PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { 126 | userKeys, ok := r.cfg.allowedUsers[ctx.User()] 127 | if !ok { 128 | return false 129 | } 130 | for _, userKey := range userKeys { 131 | if ssh.KeysEqual(key, userKey) { 132 | return true 133 | } 134 | } 135 | return false 136 | }, 137 | LocalPortForwardingCallback: ssh.LocalPortForwardingCallback(func(ctx ssh.Context, dhost string, dport uint32) bool { 138 | log.Println("Accepted forward", dhost, dport) 139 | return true 140 | }), 141 | // IdleTimeout: cfg.ClientAliveInterval, 142 | // MaxTimeout: cfg.MaxTimeout, 143 | ChannelHandlers: map[string]ssh.ChannelHandler{ 144 | // channel handler for local port forwarding 145 | "direct-tcpip": ssh.DirectTCPIPHandler, 146 | // add default session handler to show the info message. 147 | "session": ssh.DefaultSessionHandler, 148 | }, 149 | } 150 | return runServer(ctx, &srv) 151 | } 152 | 153 | func runServer(_ context.Context, srv *ssh.Server) error { 154 | if srv.Addr == "" { 155 | srv.Addr = ":22" 156 | } 157 | sigs := make(chan os.Signal, 1) 158 | signal.Notify(sigs) 159 | 160 | ln, err := net.Listen("tcp", srv.Addr) 161 | if err != nil { 162 | return fmt.Errorf("failed to listen on %s: %v", srv.Addr, err) 163 | } 164 | defer ln.Close() 165 | 166 | log.Printf("Listening on %s\n", ln.Addr().String()) 167 | 168 | go closeListener(sigs, ln) 169 | if err := srv.Serve(ln); !errors.Is(err, ssh.ErrServerClosed) { 170 | return fmt.Errorf("ListenAndServe failed: %v", err) 171 | } 172 | log.Println("Server closed") 173 | return nil 174 | } 175 | 176 | func closeListener(sigs <-chan os.Signal, ln net.Listener) { 177 | for sig := range sigs { 178 | if sig == syscall.SIGTERM || sig == syscall.SIGINT { 179 | if err := ln.Close(); err != nil { 180 | log.Printf("Failed to close listener: %v", err) 181 | os.Exit(1) 182 | } 183 | } 184 | } 185 | } 186 | 187 | type config struct { 188 | addr string 189 | signers []ssh.Signer 190 | allowedUsers map[string][]ssh.PublicKey 191 | } 192 | 193 | func (r *rootCmd) PreRun(_, runner *simplecobra.Commandeer) error { 194 | if runner.Command.Name() == "version" { 195 | return nil 196 | } 197 | if r.flags.port > 65535 { 198 | return fmt.Errorf("invalid port number: %d", r.flags.port) 199 | } 200 | if r.flags.genKeys { 201 | if err := r.generateKeys(); err != nil { 202 | return fmt.Errorf("failed to generate keys: %w", err) 203 | } 204 | } 205 | signers, err := prepareSigners(r.flags.hostKeys) 206 | if err != nil { 207 | return fmt.Errorf("failed to prepare signers: %w", err) 208 | } 209 | allowedUsers, err := allowedUsersPubKeys(r.flags.users, r.flags.pubInlines, r.flags.pubPaths) 210 | if err != nil { 211 | return fmt.Errorf("failed to prepare allowed users: %w", err) 212 | } 213 | r.cfg = config{ 214 | addr: net.JoinHostPort(r.flags.host, strconv.FormatUint(uint64(r.flags.port), 10)), 215 | signers: signers, 216 | allowedUsers: allowedUsers, 217 | } 218 | return nil 219 | } 220 | 221 | type cmdFlags struct { 222 | host string 223 | port uint 224 | hostKeys []string 225 | users []string 226 | pubInlines []string 227 | pubPaths []string 228 | genKeys bool 229 | } 230 | 231 | var defaultHostKeys = []string{ 232 | "/etc/ssh/ssh_host_rsa_key", 233 | "/etc/ssh/ssh_host_ecdsa_key", 234 | "/etc/ssh/ssh_host_ed25519_key", 235 | } 236 | 237 | func (r *rootCmd) Init(c *simplecobra.Commandeer) error { 238 | flags := c.CobraCommand.Flags() 239 | flags.StringVar(&r.flags.host, "host", "0.0.0.0", "Hostname or IP address to listen on") 240 | flags.UintVar(&r.flags.port, "port", 22, "Port to listen on") 241 | flags.StringSliceVar(&r.flags.hostKeys, "host-key", defaultHostKeys, "Host key file") 242 | flags.StringSliceVar(&r.flags.users, "user", nil, "Allowed user") 243 | flags.StringSliceVar(&r.flags.pubInlines, "public-key-inline", nil, "Public key file for user, directly as string") 244 | flags.StringSliceVar(&r.flags.pubPaths, "public-key", nil, "Path to public key file for user") 245 | flags.BoolVar(&r.flags.genKeys, "generate-host-keys", false, "Generate host keys if not exist") 246 | return nil 247 | } 248 | 249 | func (r *rootCmd) generateKeys() error { 250 | for _, keyPath := range r.flags.hostKeys { 251 | var ( 252 | blockType string 253 | bytes []byte 254 | ) 255 | keyDir, keyName := filepath.Split(keyPath) 256 | switch keyName { 257 | case "ssh_host_rsa_key": 258 | privateKey, err := rsa.GenerateKey(rand.Reader, 4096) 259 | if err != nil { 260 | return fmt.Errorf("generating RSA key: %w", err) 261 | } 262 | blockType = "RSA PRIVATE KEY" 263 | bytes = x509.MarshalPKCS1PrivateKey(privateKey) 264 | case "ssh_host_ecdsa_key": 265 | pk, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 266 | if err != nil { 267 | return fmt.Errorf("generating ECDSA key: %w", err) 268 | } 269 | blockType = "EC PRIVATE KEY" 270 | bytes, _ = x509.MarshalECPrivateKey(pk) 271 | case "ssh_host_ed25519_key": 272 | _, pk, err := ed25519.GenerateKey(rand.Reader) 273 | if err != nil { 274 | return fmt.Errorf("generating ED25519 key: %w", err) 275 | } 276 | // Use proprietary OPENSSH PRIVATE KEY format for ED25519 keys 277 | blockType = "OPENSSH PRIVATE KEY" 278 | bytes = edkey.MarshalED25519PrivateKey(pk) 279 | 280 | // Alternative and not proprietary format: PKCS#8 but others will expect the 281 | // OPENSSH format in a context of SSH server key. 282 | // 283 | // blockType = "PRIVATE KEY" 284 | // bytes, err = x509.MarshalPKCS8PrivateKey(pk) if err != nil { 285 | // return err 286 | // } 287 | default: 288 | continue 289 | } 290 | if err := os.MkdirAll(keyDir, 0o755); err != nil { 291 | return fmt.Errorf("creating directory for key: %w", err) 292 | } 293 | privatePEM := pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: bytes}) 294 | if err := writeIfNotExist(keyPath, privatePEM, 0o640); err != nil { 295 | return err 296 | } 297 | } 298 | return nil 299 | } 300 | 301 | func writeIfNotExist(path string, data []byte, perm os.FileMode) error { 302 | f, err := os.OpenFile(path, syscall.O_WRONLY|os.O_CREATE, perm) 303 | if err != nil { 304 | return err 305 | } 306 | _, err = f.Write(data) 307 | if err1 := f.Close(); err1 != nil && err == nil { 308 | err = err1 309 | } 310 | return err 311 | } 312 | 313 | func allowedUsersPubKeys(users, pubInlines, pubPaths []string) (map[string][]ssh.PublicKey, error) { 314 | if len(pubInlines) > 0 && len(pubPaths) > 0 { 315 | return nil, errors.New("cannot use both public-key and public-key-path") 316 | } 317 | pubsData := make([][]byte, 0, len(users)) 318 | switch { 319 | case len(pubInlines) > 0: 320 | for _, pub := range pubInlines { 321 | pubsData = append(pubsData, []byte(pub)) 322 | } 323 | case len(pubPaths) > 0: 324 | for _, path := range pubPaths { 325 | pubData, err := os.ReadFile(path) 326 | if err != nil { 327 | return nil, fmt.Errorf("failed to read public key file: %w", err) 328 | } 329 | pubsData = append(pubsData, pubData) 330 | } 331 | } 332 | if len(users) != len(pubsData) { 333 | return nil, errors.New("number of users and public keys does not match") 334 | } 335 | allowedUsers := make(map[string][]ssh.PublicKey) 336 | for i, user := range users { 337 | pubKey, _, _, _, err := gossh.ParseAuthorizedKey(pubsData[i]) 338 | if err != nil { 339 | return nil, fmt.Errorf("failed to parse public key: %w", err) 340 | } 341 | allowedUsers[user] = append(allowedUsers[user], pubKey) 342 | } 343 | return allowedUsers, nil 344 | } 345 | 346 | func prepareSigners(hostKeys []string) ([]ssh.Signer, error) { 347 | var signers []ssh.Signer 348 | for _, hostKey := range hostKeys { 349 | keyData, err := os.ReadFile(hostKey) 350 | if err != nil { 351 | return nil, fmt.Errorf("failed to read host key file: %w", err) 352 | } 353 | signer, err := gossh.ParsePrivateKey(keyData) 354 | if err != nil { 355 | return nil, fmt.Errorf("failed to parse private key: %w", err) 356 | } 357 | signers = append(signers, signer) 358 | } 359 | return signers, nil 360 | } 361 | --------------------------------------------------------------------------------