├── .github ├── dependabot.yml └── workflows │ ├── bump-version.yml │ ├── codeql-analysis.yml │ ├── pr-check.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── auth.go ├── auth_test.go ├── commands.go ├── config.go ├── config_test.go ├── connection.go ├── go.mod ├── go.sum ├── logging.go ├── logging_test.go ├── main.go ├── openssh.yaml ├── replay_test.go ├── replay_tests ├── direct-tcpip.json ├── misc.json ├── pty_exec.json ├── pty_shell.json ├── raw_exec.json ├── raw_shell.json ├── raw_shell_exit.json ├── root.json └── tcpip-forward.json ├── request.go ├── session.go ├── sshesame.yaml ├── tcpip.go ├── testproxy └── main.go └── testutils.go /.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 | - package-ecosystem: "gomod" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | bump-version: 8 | name: Bump version 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | - uses: anothrNick/github-tag-action@1.39.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 17 | WITH_V: true 18 | DEFAULT_BUMP: patch 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '41 11 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/pr-check.yml: -------------------------------------------------------------------------------- 1 | name: PR check 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: golangci/golangci-lint-action@v6 13 | test: 14 | name: Test 15 | strategy: 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - macos-latest 20 | - windows-latest 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: actions/setup-go@v5 25 | with: 26 | go-version: ^1.22 27 | - run: go test -race -timeout 1m 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | release-binaries: 8 | name: Release binaries 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version: "^1.22" 15 | - run: go build -o sshesame-linux-amd64 16 | env: 17 | GOOS: linux 18 | GOARCH: amd64 19 | - run: go build -o sshesame-linux-armv6 20 | env: 21 | GOOS: linux 22 | GOARCH: arm 23 | GOARM: 6 24 | - run: go build -o sshesame-linux-armv7 25 | env: 26 | GOOS: linux 27 | GOARCH: arm 28 | GOARM: 7 29 | - run: go build -o sshesame-linux-arm64 30 | env: 31 | GOOS: linux 32 | GOARCH: arm64 33 | - run: go build -o sshesame-macos-amd64 34 | env: 35 | GOOS: darwin 36 | GOARCH: amd64 37 | - run: go build -o sshesame-macos-arm64 38 | env: 39 | GOOS: darwin 40 | GOARCH: arm64 41 | - run: go build -o sshesame-windows-amd64.exe 42 | env: 43 | GOOS: windows 44 | GOARCH: amd64 45 | - run: go build -o sshesame-windows-armv7.exe 46 | env: 47 | GOOS: windows 48 | GOARCH: arm 49 | GOARM: 7 50 | - run: go build -o sshesame-windows-arm64.exe 51 | env: 52 | GOOS: windows 53 | GOARCH: arm64 54 | - uses: softprops/action-gh-release@v1 55 | with: 56 | files: | 57 | sshesame-linux-amd64 58 | sshesame-linux-armv6 59 | sshesame-linux-armv7 60 | sshesame-linux-arm64 61 | sshesame-macos-amd64 62 | sshesame-macos-arm64 63 | sshesame-windows-amd64.exe 64 | sshesame-windows-armv7.exe 65 | sshesame-windows-arm64.exe 66 | sshesame.yaml 67 | release-docker-images: 68 | env: 69 | REGISTRY: ghcr.io 70 | IMAGE_NAME: ${{ github.repository }} 71 | name: Release Docker images 72 | runs-on: ubuntu-latest 73 | permissions: 74 | contents: read 75 | packages: write 76 | steps: 77 | - uses: actions/checkout@v3 78 | - uses: docker/setup-qemu-action@v1 79 | - uses: docker/setup-buildx-action@v1 80 | - uses: docker/login-action@v1 81 | with: 82 | registry: ${{ env.REGISTRY }} 83 | username: ${{ github.actor }} 84 | password: ${{ secrets.GITHUB_TOKEN }} 85 | - id: meta 86 | uses: docker/metadata-action@v3 87 | with: 88 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 89 | - uses: docker/build-push-action@v2 90 | with: 91 | context: . 92 | platforms: linux/amd64,linux/arm64,linux/arm/v7 93 | push: true 94 | tags: ${{ steps.meta.outputs.tags }} 95 | labels: ${{ steps.meta.outputs.labels }} 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | oryxBuildBinary 8 | __debug_bin 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang as build-env 2 | WORKDIR /go/src/sshesame 3 | ADD . /go/src/sshesame 4 | RUN go build -o /go/bin/sshesame 5 | RUN sed -i 's/listen_address: .*/listen_address: 0.0.0.0:2022/' sshesame.yaml 6 | FROM gcr.io/distroless/base 7 | COPY --from=build-env /go/bin/sshesame / 8 | COPY --from=build-env /go/src/sshesame/sshesame.yaml /config.yaml 9 | EXPOSE 2022 10 | VOLUME /data 11 | CMD ["/sshesame", "-config", "/config.yaml", "-data_dir", "/data"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshesame 2 | 3 | An easy to set up and use SSH honeypot, a fake SSH server that lets anyone in and logs their activity 4 | 5 | `sshesame` accepts and logs SSH connections and activity (channels, requests), without doing anything on the host (e.g. executing commands, making network requests). 6 | 7 | [![asciicast](https://asciinema.org/a/VSqzZi1oPA0FhQDyqht22iA6k.svg)](https://asciinema.org/a/VSqzZi1oPA0FhQDyqht22iA6k) 8 | 9 | - [Installation and usage](#installation-and-usage) 10 | - [From source](#from-source) 11 | - [GitHub releases](#github-releases) 12 | - [Usage](#usage) 13 | - [Docker](#docker) 14 | - [CLI](#cli) 15 | - [Dockerfile](#dockerfile) 16 | - [Docker Compose](#docker-compose) 17 | - [systemd](#systemd) 18 | - [Configuration](#configuration) 19 | - [Sample output](#sample-output) 20 | 21 | ## Installation and usage 22 | > [!WARNING] 23 | > The [`sshesame` package](https://packages.debian.org/stable/sshesame) in the official Debian (and derivatives) repositories may be (probably is) outdated. 24 | 25 | ### From source 26 | 27 | ``` 28 | $ git clone https://github.com/jaksi/sshesame.git 29 | $ cd sshesame 30 | $ go build 31 | ``` 32 | 33 | ### GitHub releases 34 | 35 | Linux, macOS and Windows binaries for several architectures are built and released automatically and are available on the [Releases page](https://github.com/jaksi/sshesame/releases). 36 | 37 | ### Usage 38 | 39 | ``` 40 | $ sshesame -h 41 | Usage of sshesame: 42 | -config string 43 | optional config file 44 | -data_dir string 45 | data directory to store automatically generated host keys in (default "...") 46 | ``` 47 | 48 | Debug and error logs are written to standard error. Activity logs by default are written to standard out, unless the `logging.file` config option is set. 49 | 50 | ### Docker 51 | 52 | Images for amd64, arm64 and armv7 are built and published automatically and are available on the [Packages page](https://github.com/jaksi/sshesame/pkgs/container/sshesame). 53 | 54 | > [!IMPORTANT] 55 | > When using a custom config file, set `server.listen_address` to listen on all interfaces (e.g. to `0.0.0.0:2022`) to ensure Docker port forwarding works. 56 | 57 | #### CLI 58 | 59 | ``` 60 | $ docker run -it --rm\ 61 | -p 127.0.0.1:2022:2022\ 62 | -v sshesame-data:/data\ 63 | [-v $PWD/sshesame.yaml:/config.yaml]\ 64 | ghcr.io/jaksi/sshesame 65 | ``` 66 | 67 | #### Dockerfile 68 | 69 | ```dockerfile 70 | FROM ghcr.io/jaksi/sshesame 71 | #COPY sshesame.yaml /config.yaml 72 | ``` 73 | 74 | #### Docker Compose 75 | 76 | ```yaml 77 | services: 78 | sshesame: 79 | image: ghcr.io/jaksi/sshesame 80 | ports: 81 | - "127.0.0.1:2022:2022" 82 | volumes: 83 | - sshesame-data:/data 84 | #- ./sshesame.yaml:/config.yaml 85 | volumes: 86 | sshesame-data: {} 87 | ``` 88 | 89 | ### systemd 90 | 91 | ```desktop 92 | [Unit] 93 | Description=SSH honeypot 94 | After=network-online.target 95 | Wants=network-online.target 96 | 97 | [Service] 98 | ExecStart=/path/to/sshesame #-config /path/to/sshesame.yaml 99 | Restart=always 100 | 101 | [Install] 102 | WantedBy=multi-user.target 103 | ``` 104 | 105 | 106 | ### Configuration 107 | 108 | A configuration file can optionally be passed using the `-config` flag. 109 | Without specifying one, sane defaults will be used and an RSA, ECDSA and Ed25519 host key will be generated and stored in the directory specified in the `-data_dir` flag. 110 | 111 | A [sample configuration file](sshesame.yaml) with default settings and explanations for all configuration options is included. 112 | A [minimal configuration file](openssh.yaml) which tries to mimic an OpenSSH server is also included. 113 | 114 | ## Sample output 115 | 116 | ``` 117 | 2021/07/04 00:37:05 [127.0.0.1:64515] authentication for user "jaksi" without credentials rejected 118 | 2021/07/04 00:37:05 [127.0.0.1:64515] authentication for user "jaksi" with public key "SHA256:uUdTmvEHN6kCAoE4RJWsxr8+fGTGhCpAhBaWgmMVqNk" rejected 119 | 2021/07/04 00:37:07 [127.0.0.1:64515] authentication for user "jaksi" with password "hunter2" accepted 120 | 2021/07/04 00:37:07 [127.0.0.1:64515] connection with client version "SSH-2.0-OpenSSH_8.1" established 121 | 2021/07/04 00:37:07 [127.0.0.1:64515] [channel 1] session requested 122 | 2021/07/04 00:37:07 [127.0.0.1:64515] [channel 1] PTY using terminal "xterm-256color" (size 158x48) requested 123 | 2021/07/04 00:37:07 [127.0.0.1:64515] [channel 1] environment variable "LANG" with value "en_IE.UTF-8" requested 124 | 2021/07/04 00:37:07 [127.0.0.1:64515] [channel 1] shell requested 125 | 2021/07/04 00:37:16 [127.0.0.1:64515] [channel 1] input: "cat /etc/passwd" 126 | 2021/07/04 00:37:17 [127.0.0.1:64515] [channel 1] closed 127 | 2021/07/04 00:37:17 [127.0.0.1:64515] connection closed 128 | ``` 129 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promauto" 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | var authAttemptsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ 14 | Name: "sshesame_auth_attempts_total", 15 | Help: "Total number of authentication attempts", 16 | }, []string{"method", "accepted"}) 17 | 18 | func (cfg *config) getAuthLogCallback() func(conn ssh.ConnMetadata, method string, err error) { 19 | return func(conn ssh.ConnMetadata, method string, err error) { 20 | var acceptedLabel string 21 | if err == nil { 22 | acceptedLabel = "true" 23 | } else { 24 | acceptedLabel = "false" 25 | } 26 | authAttemptsMetric.WithLabelValues(method, acceptedLabel).Inc() 27 | if method == "none" { 28 | connContext{ConnMetadata: conn, cfg: cfg}.logEvent(noAuthLog{authLog: authLog{ 29 | User: conn.User(), 30 | Accepted: err == nil, 31 | }}) 32 | } 33 | } 34 | } 35 | 36 | func (cfg *config) getPasswordCallback() func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { 37 | if !cfg.Auth.PasswordAuth.Enabled { 38 | return nil 39 | } 40 | return func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { 41 | connContext{ConnMetadata: conn, cfg: cfg}.logEvent(passwordAuthLog{ 42 | authLog: authLog{ 43 | User: conn.User(), 44 | Accepted: authAccepted(cfg.Auth.PasswordAuth.Accepted), 45 | }, 46 | Password: string(password), 47 | }) 48 | if !cfg.Auth.PasswordAuth.Accepted { 49 | return nil, errors.New("") 50 | } 51 | return nil, nil 52 | } 53 | } 54 | 55 | func (cfg *config) getPublicKeyCallback() func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 56 | if !cfg.Auth.PublicKeyAuth.Enabled { 57 | return nil 58 | } 59 | return func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 60 | connContext{ConnMetadata: conn, cfg: cfg}.logEvent(publicKeyAuthLog{ 61 | authLog: authLog{ 62 | User: conn.User(), 63 | Accepted: authAccepted(cfg.Auth.PublicKeyAuth.Accepted), 64 | }, 65 | PublicKeyFingerprint: ssh.FingerprintSHA256(key), 66 | }) 67 | if !cfg.Auth.PublicKeyAuth.Accepted { 68 | return nil, errors.New("") 69 | } 70 | return nil, nil 71 | } 72 | } 73 | 74 | func (cfg *config) getKeyboardInteractiveCallback() func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { 75 | if !cfg.Auth.KeyboardInteractiveAuth.Enabled { 76 | return nil 77 | } 78 | var keyboardInteractiveQuestions []string 79 | var keyboardInteractiveEchos []bool 80 | for _, question := range cfg.Auth.KeyboardInteractiveAuth.Questions { 81 | keyboardInteractiveQuestions = append(keyboardInteractiveQuestions, question.Text) 82 | keyboardInteractiveEchos = append(keyboardInteractiveEchos, question.Echo) 83 | } 84 | return func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { 85 | answers, err := client(conn.User(), cfg.Auth.KeyboardInteractiveAuth.Instruction, keyboardInteractiveQuestions, keyboardInteractiveEchos) 86 | if err != nil { 87 | warningLogger.Printf("Failed to process keyboard interactive authentication: %v", err) 88 | return nil, errors.New("") 89 | } 90 | connContext{ConnMetadata: conn, cfg: cfg}.logEvent(keyboardInteractiveAuthLog{ 91 | authLog: authLog{ 92 | User: conn.User(), 93 | Accepted: authAccepted(cfg.Auth.KeyboardInteractiveAuth.Accepted), 94 | }, 95 | Answers: answers, 96 | }) 97 | if !cfg.Auth.KeyboardInteractiveAuth.Accepted { 98 | return nil, errors.New("") 99 | } 100 | return nil, nil 101 | } 102 | } 103 | 104 | func (cfg *config) getBannerCallback() func(conn ssh.ConnMetadata) string { 105 | if cfg.SSHProto.Banner == "" { 106 | return nil 107 | } 108 | banner := strings.ReplaceAll(strings.ReplaceAll(cfg.SSHProto.Banner, "\r\n", "\n"), "\n", "\r\n") 109 | if !strings.HasSuffix(banner, "\r\n") { 110 | banner = fmt.Sprintf("%v\r\n", banner) 111 | } 112 | return func(conn ssh.ConnMetadata) string { return banner } 113 | } 114 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | type mockConnContext struct{} 11 | 12 | func (context mockConnContext) User() string { 13 | return "root" 14 | } 15 | 16 | func (context mockConnContext) SessionID() []byte { 17 | return []byte("somesession") 18 | } 19 | 20 | func (context mockConnContext) ClientVersion() []byte { 21 | return []byte("SSH-2.0-testclient") 22 | } 23 | 24 | func (context mockConnContext) ServerVersion() []byte { 25 | return []byte("SSH-2.0-testserver") 26 | } 27 | 28 | func (context mockConnContext) RemoteAddr() net.Addr { 29 | return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1234} 30 | } 31 | 32 | func (context mockConnContext) LocalAddr() net.Addr { 33 | return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2022} 34 | } 35 | 36 | func TestAuthLogUninteresting(t *testing.T) { 37 | cfg := &config{} 38 | cfg.Auth.NoAuth = false 39 | callback := cfg.getAuthLogCallback() 40 | logBuffer := setupLogBuffer(t, cfg) 41 | callback(mockConnContext{}, "password", nil) 42 | logs := logBuffer.String() 43 | expectedLogs := `` 44 | if logs != expectedLogs { 45 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 46 | } 47 | } 48 | 49 | func TestNoAuthFail(t *testing.T) { 50 | cfg := &config{} 51 | cfg.Auth.NoAuth = false 52 | callback := cfg.getAuthLogCallback() 53 | logBuffer := setupLogBuffer(t, cfg) 54 | callback(mockConnContext{}, "none", errors.New("")) 55 | logs := logBuffer.String() 56 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" without credentials rejected 57 | ` 58 | if logs != expectedLogs { 59 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 60 | } 61 | } 62 | 63 | func TestNoAuthSuccess(t *testing.T) { 64 | cfg := &config{} 65 | cfg.Auth.NoAuth = false 66 | callback := cfg.getAuthLogCallback() 67 | logBuffer := setupLogBuffer(t, cfg) 68 | callback(mockConnContext{}, "none", nil) 69 | logs := logBuffer.String() 70 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" without credentials accepted 71 | ` 72 | if logs != expectedLogs { 73 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 74 | } 75 | } 76 | 77 | func TestPasswordDisabled(t *testing.T) { 78 | cfg := &config{} 79 | cfg.Auth.PasswordAuth.Enabled = false 80 | callback := cfg.getPasswordCallback() 81 | if callback != nil { 82 | t.Errorf("callback=%p, want nil", callback) 83 | } 84 | } 85 | 86 | func TestPasswordFail(t *testing.T) { 87 | cfg := &config{} 88 | cfg.Auth.PasswordAuth.Enabled = true 89 | cfg.Auth.PasswordAuth.Accepted = false 90 | callback := cfg.getPasswordCallback() 91 | if callback == nil { 92 | t.Fatalf("callback=nil, want a function") 93 | } 94 | logBuffer := setupLogBuffer(t, cfg) 95 | permissions, err := callback(mockConnContext{}, []byte("hunter2")) 96 | logs := logBuffer.String() 97 | if err == nil { 98 | t.Errorf("err=nil, want an error") 99 | } 100 | if permissions != nil { 101 | t.Errorf("permissions=%v, want nil", permissions) 102 | } 103 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with password "hunter2" rejected 104 | ` 105 | if logs != expectedLogs { 106 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 107 | } 108 | } 109 | 110 | func TestPasswordSuccess(t *testing.T) { 111 | cfg := &config{} 112 | cfg.Auth.PasswordAuth.Enabled = true 113 | cfg.Auth.PasswordAuth.Accepted = true 114 | callback := cfg.getPasswordCallback() 115 | if callback == nil { 116 | t.Fatalf("callback=nil, want a function") 117 | } 118 | logBuffer := setupLogBuffer(t, cfg) 119 | permissions, err := callback(mockConnContext{}, []byte("hunter2")) 120 | logs := logBuffer.String() 121 | if err != nil { 122 | t.Errorf("err=%v, want nil", err) 123 | } 124 | if permissions != nil { 125 | t.Errorf("permissions=%v, want nil", permissions) 126 | } 127 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with password "hunter2" accepted 128 | ` 129 | if logs != expectedLogs { 130 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 131 | } 132 | } 133 | 134 | func TestPasswordFailJSON(t *testing.T) { 135 | cfg := &config{} 136 | cfg.Logging.JSON = true 137 | cfg.Auth.PasswordAuth.Enabled = true 138 | cfg.Auth.PasswordAuth.Accepted = false 139 | callback := cfg.getPasswordCallback() 140 | if callback == nil { 141 | t.Fatalf("callback=nil, want a function") 142 | } 143 | logBuffer := setupLogBuffer(t, cfg) 144 | permissions, err := callback(mockConnContext{}, []byte("hunter2")) 145 | logs := logBuffer.String() 146 | if err == nil { 147 | t.Errorf("err=nil, want an error") 148 | } 149 | if permissions != nil { 150 | t.Errorf("permissions=%v, want nil", permissions) 151 | } 152 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"password_auth","event":{"user":"root","accepted":false,"password":"hunter2"}} 153 | ` 154 | if logs != expectedLogs { 155 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 156 | } 157 | } 158 | 159 | func TestPasswordSuccessJSON(t *testing.T) { 160 | cfg := &config{} 161 | cfg.Logging.JSON = true 162 | cfg.Auth.PasswordAuth.Enabled = true 163 | cfg.Auth.PasswordAuth.Accepted = true 164 | callback := cfg.getPasswordCallback() 165 | if callback == nil { 166 | t.Fatalf("callback=nil, want a function") 167 | } 168 | logBuffer := setupLogBuffer(t, cfg) 169 | permissions, err := callback(mockConnContext{}, []byte("hunter2")) 170 | logs := logBuffer.String() 171 | if err != nil { 172 | t.Errorf("err=%v, want nil", err) 173 | } 174 | if permissions != nil { 175 | t.Errorf("permissions=%v, want nil", permissions) 176 | } 177 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"password_auth","event":{"user":"root","accepted":true,"password":"hunter2"}} 178 | ` 179 | if logs != expectedLogs { 180 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 181 | } 182 | } 183 | 184 | func TestPublicKeyDisabled(t *testing.T) { 185 | cfg := &config{} 186 | cfg.Auth.PublicKeyAuth.Enabled = false 187 | callback := cfg.getPublicKeyCallback() 188 | if callback != nil { 189 | t.Errorf("callback=%p, want nil", callback) 190 | } 191 | } 192 | 193 | func TestPublicKeyFail(t *testing.T) { 194 | cfg := &config{} 195 | cfg.Auth.PublicKeyAuth.Enabled = true 196 | cfg.Auth.PublicKeyAuth.Accepted = false 197 | callback := cfg.getPublicKeyCallback() 198 | if callback == nil { 199 | t.Fatalf("callback=nil, want a function") 200 | } 201 | logBuffer := setupLogBuffer(t, cfg) 202 | permissions, err := callback(mockConnContext{}, mockPublicKey{}) 203 | logs := logBuffer.String() 204 | if err == nil { 205 | t.Errorf("err=nil, want an error") 206 | } 207 | if permissions != nil { 208 | t.Errorf("permissions=%v, want nil", permissions) 209 | } 210 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with public key "SHA256:9faRaLujz6HiqA3/g5tI2zbfNvqHbBzZ19UI86swh0Q" rejected 211 | ` 212 | if logs != expectedLogs { 213 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 214 | } 215 | } 216 | 217 | func TestPublicKeySuccess(t *testing.T) { 218 | cfg := &config{} 219 | cfg.Auth.PublicKeyAuth.Enabled = true 220 | cfg.Auth.PublicKeyAuth.Accepted = true 221 | callback := cfg.getPublicKeyCallback() 222 | if callback == nil { 223 | t.Fatalf("callback=nil, want a function") 224 | } 225 | logBuffer := setupLogBuffer(t, cfg) 226 | permissions, err := callback(mockConnContext{}, mockPublicKey{}) 227 | logs := logBuffer.String() 228 | if err != nil { 229 | t.Errorf("err=%v, want nil", err) 230 | } 231 | if permissions != nil { 232 | t.Errorf("permissions=%v, want nil", permissions) 233 | } 234 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with public key "SHA256:9faRaLujz6HiqA3/g5tI2zbfNvqHbBzZ19UI86swh0Q" accepted 235 | ` 236 | if logs != expectedLogs { 237 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 238 | } 239 | } 240 | 241 | func TestPublicKeyFailJSON(t *testing.T) { 242 | cfg := &config{} 243 | cfg.Logging.JSON = true 244 | cfg.Auth.PublicKeyAuth.Enabled = true 245 | cfg.Auth.PublicKeyAuth.Accepted = false 246 | callback := cfg.getPublicKeyCallback() 247 | if callback == nil { 248 | t.Fatalf("callback=nil, want a function") 249 | } 250 | logBuffer := setupLogBuffer(t, cfg) 251 | permissions, err := callback(mockConnContext{}, mockPublicKey{}) 252 | logs := logBuffer.String() 253 | if err == nil { 254 | t.Errorf("err=nil, want an error") 255 | } 256 | if permissions != nil { 257 | t.Errorf("permissions=%v, want nil", permissions) 258 | } 259 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"public_key_auth","event":{"user":"root","accepted":false,"public_key":"SHA256:9faRaLujz6HiqA3/g5tI2zbfNvqHbBzZ19UI86swh0Q"}} 260 | ` 261 | if logs != expectedLogs { 262 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 263 | } 264 | } 265 | 266 | func TestPublicKeySuccessJSON(t *testing.T) { 267 | cfg := &config{} 268 | cfg.Logging.JSON = true 269 | cfg.Auth.PublicKeyAuth.Enabled = true 270 | cfg.Auth.PublicKeyAuth.Accepted = true 271 | callback := cfg.getPublicKeyCallback() 272 | if callback == nil { 273 | t.Fatalf("callback=nil, want a function") 274 | } 275 | logBuffer := setupLogBuffer(t, cfg) 276 | permissions, err := callback(mockConnContext{}, mockPublicKey{}) 277 | logs := logBuffer.String() 278 | if err != nil { 279 | t.Errorf("err=%v, want nil", err) 280 | } 281 | if permissions != nil { 282 | t.Errorf("permissions=%v, want nil", permissions) 283 | } 284 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"public_key_auth","event":{"user":"root","accepted":true,"public_key":"SHA256:9faRaLujz6HiqA3/g5tI2zbfNvqHbBzZ19UI86swh0Q"}} 285 | ` 286 | if logs != expectedLogs { 287 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 288 | } 289 | } 290 | 291 | func TestKeyboardInteractiveDisabled(t *testing.T) { 292 | cfg := &config{} 293 | cfg.Auth.KeyboardInteractiveAuth.Enabled = false 294 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 295 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 296 | {"q1", true}, 297 | {"q2", false}, 298 | } 299 | callback := cfg.getKeyboardInteractiveCallback() 300 | if callback != nil { 301 | t.Errorf("callback=%p, want nil", callback) 302 | } 303 | } 304 | 305 | func TestKeyboardInteractiveError(t *testing.T) { 306 | cfg := &config{} 307 | cfg.Auth.KeyboardInteractiveAuth.Enabled = true 308 | cfg.Auth.KeyboardInteractiveAuth.Accepted = false 309 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 310 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 311 | {"q1", true}, 312 | {"q2", false}, 313 | } 314 | callback := cfg.getKeyboardInteractiveCallback() 315 | if callback == nil { 316 | t.Fatalf("callback=nil, want a function") 317 | } 318 | logBuffer := setupLogBuffer(t, cfg) 319 | permissions, err := callback(mockConnContext{}, func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 320 | if user != "root" { 321 | t.Errorf("user=%v, want root", user) 322 | } 323 | if instruction != "inst" { 324 | t.Errorf("instruction=%v, want inst", instruction) 325 | } 326 | if !reflect.DeepEqual(questions, []string{"q1", "q2"}) { 327 | t.Errorf("questions=%v, want [q1, q2]", questions) 328 | } 329 | if !reflect.DeepEqual(echos, []bool{true, false}) { 330 | t.Errorf("echos=%v, want [true, false]", echos) 331 | } 332 | return nil, errors.New("") 333 | }) 334 | logs := logBuffer.String() 335 | if err == nil { 336 | t.Errorf("err=nil, want an error") 337 | } 338 | if permissions != nil { 339 | t.Errorf("permissions=%v, want nil", permissions) 340 | } 341 | expectedLogs := `` 342 | if logs != expectedLogs { 343 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 344 | } 345 | } 346 | 347 | func TestKeyboardInteractiveFail(t *testing.T) { 348 | cfg := &config{} 349 | cfg.Auth.KeyboardInteractiveAuth.Enabled = true 350 | cfg.Auth.KeyboardInteractiveAuth.Accepted = false 351 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 352 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 353 | {"q1", true}, 354 | {"q2", false}, 355 | } 356 | callback := cfg.getKeyboardInteractiveCallback() 357 | if callback == nil { 358 | t.Fatalf("callback=nil, want a function") 359 | } 360 | logBuffer := setupLogBuffer(t, cfg) 361 | permissions, err := callback(mockConnContext{}, func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 362 | return []string{"a1", "a2"}, nil 363 | }) 364 | logs := logBuffer.String() 365 | if err == nil { 366 | t.Errorf("err=nil, want an error") 367 | } 368 | if permissions != nil { 369 | t.Errorf("permissions=%v, want nil", permissions) 370 | } 371 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with keyboard interactive answers ["a1" "a2"] rejected 372 | ` 373 | if logs != expectedLogs { 374 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 375 | } 376 | } 377 | 378 | func TestKeyboardInteractiveSuccess(t *testing.T) { 379 | cfg := &config{} 380 | cfg.Auth.KeyboardInteractiveAuth.Enabled = true 381 | cfg.Auth.KeyboardInteractiveAuth.Accepted = true 382 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 383 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 384 | {"q1", true}, 385 | {"q2", false}, 386 | } 387 | callback := cfg.getKeyboardInteractiveCallback() 388 | if callback == nil { 389 | t.Fatalf("callback=nil, want a function") 390 | } 391 | logBuffer := setupLogBuffer(t, cfg) 392 | permissions, err := callback(mockConnContext{}, func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 393 | return []string{"a1", "a2"}, nil 394 | }) 395 | logs := logBuffer.String() 396 | if err != nil { 397 | t.Errorf("err=%v, want nil", err) 398 | } 399 | if permissions != nil { 400 | t.Errorf("permissions=%v, want nil", permissions) 401 | } 402 | expectedLogs := `[127.0.0.1:1234] authentication for user "root" with keyboard interactive answers ["a1" "a2"] accepted 403 | ` 404 | if logs != expectedLogs { 405 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 406 | } 407 | } 408 | 409 | func TestKeyboardInteractiveFailJSON(t *testing.T) { 410 | cfg := &config{} 411 | cfg.Logging.JSON = true 412 | cfg.Auth.KeyboardInteractiveAuth.Enabled = true 413 | cfg.Auth.KeyboardInteractiveAuth.Accepted = false 414 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 415 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 416 | {"q1", true}, 417 | {"q2", false}, 418 | } 419 | callback := cfg.getKeyboardInteractiveCallback() 420 | if callback == nil { 421 | t.Fatalf("callback=nil, want a function") 422 | } 423 | logBuffer := setupLogBuffer(t, cfg) 424 | permissions, err := callback(mockConnContext{}, func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 425 | return []string{"a1", "a2"}, nil 426 | }) 427 | logs := logBuffer.String() 428 | if err == nil { 429 | t.Errorf("err=nil, want an error") 430 | } 431 | if permissions != nil { 432 | t.Errorf("permissions=%v, want nil", permissions) 433 | } 434 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"keyboard_interactive_auth","event":{"user":"root","accepted":false,"answers":["a1","a2"]}} 435 | ` 436 | if logs != expectedLogs { 437 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 438 | } 439 | } 440 | 441 | func TestKeyboardInteractiveSuccessJSON(t *testing.T) { 442 | cfg := &config{} 443 | cfg.Logging.JSON = true 444 | cfg.Auth.KeyboardInteractiveAuth.Enabled = true 445 | cfg.Auth.KeyboardInteractiveAuth.Accepted = true 446 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "inst" 447 | cfg.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 448 | {"q1", true}, 449 | {"q2", false}, 450 | } 451 | callback := cfg.getKeyboardInteractiveCallback() 452 | if callback == nil { 453 | t.Fatalf("callback=nil, want a function") 454 | } 455 | logBuffer := setupLogBuffer(t, cfg) 456 | permissions, err := callback(mockConnContext{}, func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 457 | return []string{"a1", "a2"}, nil 458 | }) 459 | logs := logBuffer.String() 460 | if err != nil { 461 | t.Errorf("err=%v, want nil", err) 462 | } 463 | if permissions != nil { 464 | t.Errorf("permissions=%v, want nil", permissions) 465 | } 466 | expectedLogs := `{"source":"127.0.0.1:1234","event_type":"keyboard_interactive_auth","event":{"user":"root","accepted":true,"answers":["a1","a2"]}} 467 | ` 468 | if logs != expectedLogs { 469 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 470 | } 471 | } 472 | 473 | func TestBannerDisabled(t *testing.T) { 474 | cfg := &config{} 475 | cfg.SSHProto.Banner = "" 476 | callback := cfg.getBannerCallback() 477 | if callback != nil { 478 | t.Errorf("callback=%p, want nil", callback) 479 | } 480 | } 481 | 482 | func TestBanner(t *testing.T) { 483 | cfg := &config{} 484 | cfg.SSHProto.Banner = "Lorem\nIpsum\r\nDolor\n\nSit Amet" 485 | callback := cfg.getBannerCallback() 486 | if callback == nil { 487 | t.Fatalf("callback=nil, want a function") 488 | } 489 | banner := callback(mockConnContext{}) 490 | expectedBanner := "Lorem\r\nIpsum\r\nDolor\r\n\r\nSit Amet\r\n" 491 | if banner != expectedBanner { 492 | t.Errorf("banner=%v, want %v", banner, expectedBanner) 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type readLiner interface { 11 | ReadLine() (string, error) 12 | } 13 | 14 | type commandContext struct { 15 | args []string 16 | stdin readLiner 17 | stdout, stderr io.Writer 18 | pty bool 19 | user string 20 | } 21 | 22 | type command interface { 23 | execute(context commandContext) (uint32, error) 24 | } 25 | 26 | var commands = map[string]command{ 27 | "sh": cmdShell{}, 28 | "true": cmdTrue{}, 29 | "false": cmdFalse{}, 30 | "echo": cmdEcho{}, 31 | "cat": cmdCat{}, 32 | "su": cmdSu{}, 33 | } 34 | 35 | var shellProgram = []string{"sh"} 36 | 37 | func executeProgram(context commandContext) (uint32, error) { 38 | if len(context.args) == 0 { 39 | return 0, nil 40 | } 41 | command := commands[context.args[0]] 42 | if command == nil { 43 | _, err := fmt.Fprintf(context.stderr, "%v: command not found\n", context.args[0]) 44 | return 127, err 45 | } 46 | return command.execute(context) 47 | } 48 | 49 | type cmdShell struct{} 50 | 51 | func (cmdShell) execute(context commandContext) (uint32, error) { 52 | var prompt string 53 | if context.pty { 54 | switch context.user { 55 | case "root": 56 | prompt = "# " 57 | default: 58 | prompt = "$ " 59 | } 60 | } 61 | var lastStatus uint32 62 | var line string 63 | var err error 64 | for { 65 | _, err = fmt.Fprint(context.stdout, prompt) 66 | if err != nil { 67 | return lastStatus, err 68 | } 69 | line, err = context.stdin.ReadLine() 70 | if err != nil { 71 | return lastStatus, err 72 | } 73 | args := strings.Fields(line) 74 | if len(args) == 0 { 75 | continue 76 | } 77 | if args[0] == "exit" { 78 | var err error 79 | var status uint64 = uint64(lastStatus) 80 | if len(args) > 1 { 81 | status, err = strconv.ParseUint(args[1], 10, 32) 82 | if err != nil { 83 | status = 255 84 | } 85 | } 86 | return uint32(status), nil 87 | } 88 | newContext := context 89 | newContext.args = args 90 | if lastStatus, err = executeProgram(newContext); err != nil { 91 | return lastStatus, err 92 | } 93 | } 94 | } 95 | 96 | type cmdTrue struct{} 97 | 98 | func (cmdTrue) execute(context commandContext) (uint32, error) { 99 | return 0, nil 100 | } 101 | 102 | type cmdFalse struct{} 103 | 104 | func (cmdFalse) execute(context commandContext) (uint32, error) { 105 | return 1, nil 106 | } 107 | 108 | type cmdEcho struct{} 109 | 110 | func (cmdEcho) execute(context commandContext) (uint32, error) { 111 | _, err := fmt.Fprintln(context.stdout, strings.Join(context.args[1:], " ")) 112 | return 0, err 113 | } 114 | 115 | type cmdCat struct{} 116 | 117 | func (cmdCat) execute(context commandContext) (uint32, error) { 118 | if len(context.args) > 1 { 119 | for _, file := range context.args[1:] { 120 | if _, err := fmt.Fprintf(context.stderr, "%v: %v: No such file or directory\n", context.args[0], file); err != nil { 121 | return 0, err 122 | } 123 | } 124 | return 1, nil 125 | } 126 | var line string 127 | var err error 128 | for err == nil { 129 | line, err = context.stdin.ReadLine() 130 | if err == nil { 131 | _, err = fmt.Fprintln(context.stdout, line) 132 | } 133 | } 134 | return 0, err 135 | } 136 | 137 | type cmdSu struct{} 138 | 139 | func (cmdSu) execute(context commandContext) (uint32, error) { 140 | newContext := context 141 | newContext.user = "root" 142 | if len(context.args) > 1 { 143 | newContext.user = context.args[1] 144 | } 145 | newContext.args = shellProgram 146 | return executeProgram(newContext) 147 | } 148 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/ed25519" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "encoding/pem" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "log" 15 | "os" 16 | "path" 17 | 18 | "golang.org/x/crypto/ssh" 19 | "gopkg.in/yaml.v2" 20 | ) 21 | 22 | type serverConfig struct { 23 | ListenAddress string `yaml:"listen_address"` 24 | HostKeys []string `yaml:"host_keys"` 25 | TCPIPServices map[uint32]string `yaml:"tcpip_services"` 26 | } 27 | 28 | type loggingConfig struct { 29 | File string `yaml:"file"` 30 | JSON bool `yaml:"json"` 31 | Timestamps bool `yaml:"timestamps"` 32 | MetricsAddress string `yaml:"metrics_address"` 33 | Debug bool `yaml:"debug"` 34 | SplitHostPort bool `yaml:"split_host_port"` 35 | } 36 | 37 | type commonAuthConfig struct { 38 | Enabled bool `yaml:"enabled"` 39 | Accepted bool `yaml:"accepted"` 40 | } 41 | 42 | type keyboardInteractiveAuthQuestion struct { 43 | Text string `yaml:"text"` 44 | Echo bool `yaml:"echo"` 45 | } 46 | 47 | type keyboardInteractiveAuthConfig struct { 48 | commonAuthConfig `yaml:",inline"` 49 | Instruction string `yaml:"instruction"` 50 | Questions []keyboardInteractiveAuthQuestion `yaml:"questions"` 51 | } 52 | 53 | type authConfig struct { 54 | MaxTries int `yaml:"max_tries"` 55 | NoAuth bool `yaml:"no_auth"` 56 | PasswordAuth commonAuthConfig `yaml:"password_auth"` 57 | PublicKeyAuth commonAuthConfig `yaml:"public_key_auth"` 58 | KeyboardInteractiveAuth keyboardInteractiveAuthConfig `yaml:"keyboard_interactive_auth"` 59 | } 60 | 61 | type sshProtoConfig struct { 62 | Version string `yaml:"version"` 63 | Banner string `yaml:"banner"` 64 | RekeyThreshold uint64 `yaml:"rekey_threshold"` 65 | KeyExchanges []string `yaml:"key_exchanges"` 66 | Ciphers []string `yaml:"ciphers"` 67 | MACs []string `yaml:"macs"` 68 | } 69 | 70 | type config struct { 71 | Server serverConfig `yaml:"server"` 72 | Logging loggingConfig `yaml:"logging"` 73 | Auth authConfig `yaml:"auth"` 74 | SSHProto sshProtoConfig `yaml:"ssh_proto"` 75 | 76 | parsedHostKeys []ssh.Signer 77 | sshConfig *ssh.ServerConfig 78 | logFileHandle io.WriteCloser 79 | } 80 | 81 | func (cfg *config) setDefaults() { 82 | cfg.Server.ListenAddress = "127.0.0.1:2022" 83 | cfg.Logging.Timestamps = true 84 | cfg.Auth.PasswordAuth.Enabled = true 85 | cfg.Auth.PasswordAuth.Accepted = true 86 | cfg.Auth.PublicKeyAuth.Enabled = true 87 | cfg.SSHProto.Version = "SSH-2.0-sshesame" 88 | cfg.SSHProto.Banner = "This is an SSH honeypot. Everything is logged and monitored." 89 | } 90 | 91 | var defaultTCPIPServices = map[uint32]string{ 92 | 25: "SMTP", 93 | 80: "HTTP", 94 | 110: "POP3", 95 | 587: "SMTP", 96 | 8080: "HTTP", 97 | } 98 | 99 | type keySignature int 100 | 101 | const ( 102 | rsa_key keySignature = iota 103 | ecdsa_key 104 | ed25519_key 105 | ) 106 | 107 | func (signature keySignature) String() string { 108 | switch signature { 109 | case rsa_key: 110 | return "rsa" 111 | case ecdsa_key: 112 | return "ecdsa" 113 | case ed25519_key: 114 | return "ed25519" 115 | default: 116 | return "unknown" 117 | } 118 | } 119 | 120 | func generateKey(dataDir string, signature keySignature) (string, error) { 121 | keyFile := path.Join(dataDir, fmt.Sprintf("host_%v_key", signature)) 122 | if _, err := os.Stat(keyFile); err == nil { 123 | return keyFile, nil 124 | } else if !os.IsNotExist(err) { 125 | return "", err 126 | } 127 | infoLogger.Printf("Host key %q not found, generating it", keyFile) 128 | if _, err := os.Stat(path.Dir(keyFile)); os.IsNotExist(err) { 129 | if err := os.MkdirAll(path.Dir(keyFile), 0755); err != nil { 130 | return "", err 131 | } 132 | } 133 | var key interface{} 134 | err := errors.New("unsupported key type") 135 | switch signature { 136 | case rsa_key: 137 | key, err = rsa.GenerateKey(rand.Reader, 3072) 138 | case ecdsa_key: 139 | key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 140 | case ed25519_key: 141 | _, key, err = ed25519.GenerateKey(rand.Reader) 142 | } 143 | if err != nil { 144 | return "", err 145 | } 146 | keyBytes, err := x509.MarshalPKCS8PrivateKey(key) 147 | if err != nil { 148 | return "", err 149 | } 150 | if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}), 0600); err != nil { 151 | return "", err 152 | } 153 | return keyFile, nil 154 | } 155 | 156 | func loadKey(keyFile string) (ssh.Signer, error) { 157 | keyBytes, err := os.ReadFile(keyFile) 158 | if err != nil { 159 | return nil, err 160 | } 161 | return ssh.ParsePrivateKey(keyBytes) 162 | } 163 | 164 | func (cfg *config) setDefaultHostKeys(dataDir string, signatures []keySignature) error { 165 | for _, signature := range signatures { 166 | keyFile, err := generateKey(dataDir, signature) 167 | if err != nil { 168 | return err 169 | } 170 | cfg.Server.HostKeys = append(cfg.Server.HostKeys, keyFile) 171 | } 172 | return nil 173 | } 174 | 175 | func (cfg *config) parseHostKeys() error { 176 | for _, keyFile := range cfg.Server.HostKeys { 177 | signer, err := loadKey(keyFile) 178 | if err != nil { 179 | return err 180 | } 181 | cfg.parsedHostKeys = append(cfg.parsedHostKeys, signer) 182 | } 183 | return nil 184 | } 185 | 186 | func (cfg *config) setupSSHConfig() error { 187 | sshConfig := &ssh.ServerConfig{ 188 | Config: ssh.Config{ 189 | RekeyThreshold: cfg.SSHProto.RekeyThreshold, 190 | KeyExchanges: cfg.SSHProto.KeyExchanges, 191 | Ciphers: cfg.SSHProto.Ciphers, 192 | MACs: cfg.SSHProto.MACs, 193 | }, 194 | NoClientAuth: cfg.Auth.NoAuth, 195 | MaxAuthTries: cfg.Auth.MaxTries, 196 | PasswordCallback: cfg.getPasswordCallback(), 197 | PublicKeyCallback: cfg.getPublicKeyCallback(), 198 | KeyboardInteractiveCallback: cfg.getKeyboardInteractiveCallback(), 199 | AuthLogCallback: cfg.getAuthLogCallback(), 200 | ServerVersion: cfg.SSHProto.Version, 201 | BannerCallback: cfg.getBannerCallback(), 202 | } 203 | if err := cfg.parseHostKeys(); err != nil { 204 | return err 205 | } 206 | for _, key := range cfg.parsedHostKeys { 207 | sshConfig.AddHostKey(key) 208 | } 209 | cfg.sshConfig = sshConfig 210 | return nil 211 | } 212 | 213 | func (cfg *config) setupLogging() error { 214 | var logFile io.WriteCloser 215 | if cfg.Logging.File != "" { 216 | var err error 217 | logFile, err = os.OpenFile(cfg.Logging.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 218 | if err != nil { 219 | return err 220 | } 221 | } 222 | if logFile == nil { 223 | log.SetOutput(os.Stdout) 224 | } else { 225 | log.SetOutput(logFile) 226 | } 227 | if cfg.logFileHandle != nil { 228 | cfg.logFileHandle.Close() 229 | } 230 | cfg.logFileHandle = logFile 231 | if !cfg.Logging.JSON && cfg.Logging.Timestamps { 232 | log.SetFlags(log.LstdFlags) 233 | } else { 234 | log.SetFlags(0) 235 | } 236 | return nil 237 | } 238 | 239 | func (cfg *config) load(configString string, dataDir string) error { 240 | *cfg = config{} 241 | 242 | cfg.setDefaults() 243 | 244 | if err := yaml.UnmarshalStrict([]byte(configString), cfg); err != nil { 245 | return err 246 | } 247 | 248 | if cfg.Server.TCPIPServices == nil { 249 | cfg.Server.TCPIPServices = defaultTCPIPServices 250 | } 251 | 252 | for _, service := range cfg.Server.TCPIPServices { 253 | if _, ok := servers[service]; !ok { 254 | return fmt.Errorf("unknown service %q", service) 255 | } 256 | } 257 | 258 | if len(cfg.Server.HostKeys) == 0 { 259 | infoLogger.Printf("No host keys configured, using keys at %q", dataDir) 260 | if err := cfg.setDefaultHostKeys(dataDir, []keySignature{rsa_key, ecdsa_key, ed25519_key}); err != nil { 261 | return err 262 | } 263 | } 264 | 265 | if err := cfg.setupSSHConfig(); err != nil { 266 | return err 267 | } 268 | if err := cfg.setupLogging(); err != nil { 269 | return err 270 | } 271 | 272 | return nil 273 | } 274 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path" 9 | "reflect" 10 | "testing" 11 | 12 | "golang.org/x/crypto/ssh" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | type mockPublicKey struct { 17 | signature keySignature 18 | } 19 | 20 | func (publicKey mockPublicKey) Type() string { 21 | return publicKey.signature.String() 22 | } 23 | 24 | func (publicKey mockPublicKey) Marshal() []byte { 25 | return []byte(publicKey.signature.String()) 26 | } 27 | 28 | func (publicKey mockPublicKey) Verify(data []byte, sig *ssh.Signature) error { 29 | return nil 30 | } 31 | 32 | type mockFile struct { 33 | closed bool 34 | } 35 | 36 | func (file *mockFile) Write(p []byte) (n int, err error) { 37 | return 0, errors.New("") 38 | } 39 | 40 | func (file *mockFile) Close() error { 41 | if file.closed { 42 | return errors.New("") 43 | } 44 | file.closed = true 45 | return nil 46 | } 47 | 48 | func verifyConfig(t *testing.T, cfg *config, expected *config) { 49 | if !reflect.DeepEqual(cfg.Server, expected.Server) { 50 | t.Errorf("Server=%v, want %v", cfg.Server, expected.Server) 51 | } 52 | if !reflect.DeepEqual(cfg.Logging, expected.Logging) { 53 | t.Errorf("Logging=%v, want %v", cfg.Logging, expected.Logging) 54 | } 55 | if !reflect.DeepEqual(cfg.Auth, expected.Auth) { 56 | t.Errorf("Auth=%v, want %v", cfg.Auth, expected.Auth) 57 | } 58 | if !reflect.DeepEqual(cfg.SSHProto, expected.SSHProto) { 59 | t.Errorf("SSHProto=%v, want %v", cfg.SSHProto, expected.SSHProto) 60 | } 61 | 62 | if cfg.sshConfig.RekeyThreshold != expected.SSHProto.RekeyThreshold { 63 | t.Errorf("sshConfig.RekeyThreshold=%v, want %v", cfg.sshConfig.RekeyThreshold, expected.SSHProto.RekeyThreshold) 64 | } 65 | if !reflect.DeepEqual(cfg.sshConfig.KeyExchanges, expected.SSHProto.KeyExchanges) { 66 | t.Errorf("sshConfig.KeyExchanges=%v, want %v", cfg.sshConfig.KeyExchanges, expected.SSHProto.KeyExchanges) 67 | } 68 | if !reflect.DeepEqual(cfg.sshConfig.Ciphers, expected.SSHProto.Ciphers) { 69 | t.Errorf("sshConfig.Ciphers=%v, want %v", cfg.sshConfig.Ciphers, expected.SSHProto.Ciphers) 70 | } 71 | if !reflect.DeepEqual(cfg.sshConfig.MACs, expected.SSHProto.MACs) { 72 | t.Errorf("sshConfig.MACs=%v, want %v", cfg.sshConfig.MACs, expected.SSHProto.MACs) 73 | } 74 | if cfg.sshConfig.NoClientAuth != expected.Auth.NoAuth { 75 | t.Errorf("sshConfig.NoClientAuth=%v, want %v", cfg.sshConfig.NoClientAuth, expected.Auth.NoAuth) 76 | } 77 | if cfg.sshConfig.MaxAuthTries != expected.Auth.MaxTries { 78 | t.Errorf("sshConfig.MaxAuthTries=%v, want %v", cfg.sshConfig.MaxAuthTries, expected.Auth.MaxTries) 79 | } 80 | if (cfg.sshConfig.PasswordCallback != nil) != expected.Auth.PasswordAuth.Enabled { 81 | t.Errorf("sshConfig.PasswordCallback=%v, want %v", cfg.sshConfig.PasswordCallback != nil, expected.Auth.PasswordAuth.Enabled) 82 | } 83 | if (cfg.sshConfig.PublicKeyCallback != nil) != expected.Auth.PublicKeyAuth.Enabled { 84 | t.Errorf("sshConfig.PasswordCallback=%v, want %v", cfg.sshConfig.PublicKeyCallback != nil, expected.Auth.PublicKeyAuth.Enabled) 85 | } 86 | if (cfg.sshConfig.KeyboardInteractiveCallback != nil) != expected.Auth.KeyboardInteractiveAuth.Enabled { 87 | t.Errorf("sshConfig.KeyboardInteractiveCallback=%v, want %v", cfg.sshConfig.KeyboardInteractiveCallback != nil, expected.Auth.KeyboardInteractiveAuth.Enabled) 88 | } 89 | if cfg.sshConfig.AuthLogCallback == nil { 90 | t.Errorf("sshConfig.AuthLogCallback=nil, want a callback") 91 | } 92 | if cfg.sshConfig.ServerVersion != expected.SSHProto.Version { 93 | t.Errorf("sshConfig.ServerVersion=%v, want %v", cfg.sshConfig.ServerVersion, expected.SSHProto.Version) 94 | } 95 | if (cfg.sshConfig.BannerCallback != nil) != (expected.SSHProto.Banner != "") { 96 | t.Errorf("sshConfig.BannerCallback=%v, want %v", cfg.sshConfig.BannerCallback != nil, expected.SSHProto.Banner != "") 97 | } 98 | if cfg.sshConfig.GSSAPIWithMICConfig != nil { 99 | t.Errorf("sshConfig.GSSAPIWithMICConfig=%v, want nil", cfg.sshConfig.GSSAPIWithMICConfig) 100 | } 101 | if len(cfg.parsedHostKeys) != len(expected.Server.HostKeys) { 102 | t.Errorf("len(parsedHostKeys)=%v, want %v", len(cfg.parsedHostKeys), len(expected.Server.HostKeys)) 103 | } 104 | 105 | if expected.Logging.File == "" { 106 | if cfg.logFileHandle != nil { 107 | t.Errorf("logFileHandle=%v, want nil", cfg.logFileHandle) 108 | } 109 | } else { 110 | if cfg.logFileHandle == nil { 111 | t.Errorf("logFileHandle=nil, want a file") 112 | } 113 | } 114 | } 115 | 116 | func verifyDefaultKeys(t *testing.T, dataDir string) { 117 | files, err := os.ReadDir(dataDir) 118 | if err != nil { 119 | t.Fatalf("Faield to list directory: %v", err) 120 | } 121 | expectedKeys := map[string]string{ 122 | "host_rsa_key": "ssh-rsa", 123 | "host_ecdsa_key": "ecdsa-sha2-nistp256", 124 | "host_ed25519_key": "ssh-ed25519", 125 | } 126 | keys := map[string]string{} 127 | for _, file := range files { 128 | keyBytes, err := os.ReadFile(path.Join(dataDir, file.Name())) 129 | if err != nil { 130 | t.Fatalf("Failed to read key: %v", err) 131 | } 132 | signer, err := ssh.ParsePrivateKey(keyBytes) 133 | if err != nil { 134 | t.Fatalf("Failed to parse private key: %v", err) 135 | } 136 | keys[file.Name()] = signer.PublicKey().Type() 137 | } 138 | if !reflect.DeepEqual(keys, expectedKeys) { 139 | t.Errorf("keys=%v, want %v", keys, expectedKeys) 140 | } 141 | } 142 | 143 | func TestDefaultConfig(t *testing.T) { 144 | dataDir := t.TempDir() 145 | cfg := &config{} 146 | err := cfg.load("", dataDir) 147 | if err != nil { 148 | t.Fatalf("Failed to get config: %v", err) 149 | } 150 | expectedConfig := &config{} 151 | expectedConfig.Server.ListenAddress = "127.0.0.1:2022" 152 | expectedConfig.Server.HostKeys = []string{ 153 | path.Join(dataDir, "host_rsa_key"), 154 | path.Join(dataDir, "host_ecdsa_key"), 155 | path.Join(dataDir, "host_ed25519_key"), 156 | } 157 | expectedConfig.Server.TCPIPServices = map[uint32]string{ 158 | 25: "SMTP", 159 | 80: "HTTP", 160 | 110: "POP3", 161 | 587: "SMTP", 162 | 8080: "HTTP", 163 | } 164 | expectedConfig.Logging.Timestamps = true 165 | expectedConfig.Auth.PasswordAuth.Enabled = true 166 | expectedConfig.Auth.PasswordAuth.Accepted = true 167 | expectedConfig.Auth.PublicKeyAuth.Enabled = true 168 | expectedConfig.SSHProto.Version = "SSH-2.0-sshesame" 169 | expectedConfig.SSHProto.Banner = "This is an SSH honeypot. Everything is logged and monitored." 170 | verifyConfig(t, cfg, expectedConfig) 171 | verifyDefaultKeys(t, dataDir) 172 | } 173 | 174 | func TestUserConfigDefaultKeys(t *testing.T) { 175 | logFile := path.Join(t.TempDir(), "test.log") 176 | cfgString := fmt.Sprintf(` 177 | server: 178 | listen_address: 0.0.0.0:22 179 | tcpip_services: {} 180 | logging: 181 | file: %v 182 | json: true 183 | timestamps: false 184 | metrics_address: 0.0.0.0:2112 185 | split_host_port: true 186 | auth: 187 | max_tries: 234 188 | no_auth: true 189 | password_auth: 190 | enabled: false 191 | accepted: false 192 | public_key_auth: 193 | enabled: false 194 | accepted: true 195 | keyboard_interactive_auth: 196 | enabled: true 197 | accepted: true 198 | instruction: instruction 199 | questions: 200 | - text: q1 201 | echo: true 202 | - text: q2 203 | echo: false 204 | ssh_proto: 205 | version: SSH-2.0-test 206 | banner: 207 | rekey_threshold: 123 208 | key_exchanges: [kex] 209 | ciphers: [cipher] 210 | macs: [mac] 211 | `, logFile) 212 | dataDir := t.TempDir() 213 | writeTestKeys(t, dataDir) 214 | cfg := &config{} 215 | err := cfg.load(cfgString, dataDir) 216 | if err != nil { 217 | t.Fatalf("Failed to get config: %v", err) 218 | } 219 | if cfg.logFileHandle != nil { 220 | cfg.logFileHandle.Close() 221 | } 222 | expectedConfig := &config{} 223 | expectedConfig.Server.ListenAddress = "0.0.0.0:22" 224 | expectedConfig.Server.HostKeys = []string{ 225 | path.Join(dataDir, "host_rsa_key"), 226 | path.Join(dataDir, "host_ecdsa_key"), 227 | path.Join(dataDir, "host_ed25519_key"), 228 | } 229 | expectedConfig.Server.TCPIPServices = map[uint32]string{} 230 | expectedConfig.Logging.File = logFile 231 | expectedConfig.Logging.JSON = true 232 | expectedConfig.Logging.Timestamps = false 233 | expectedConfig.Logging.MetricsAddress = "0.0.0.0:2112" 234 | expectedConfig.Logging.SplitHostPort = true 235 | expectedConfig.Auth.MaxTries = 234 236 | expectedConfig.Auth.NoAuth = true 237 | expectedConfig.Auth.PublicKeyAuth.Accepted = true 238 | expectedConfig.Auth.KeyboardInteractiveAuth.Enabled = true 239 | expectedConfig.Auth.KeyboardInteractiveAuth.Accepted = true 240 | expectedConfig.Auth.KeyboardInteractiveAuth.Instruction = "instruction" 241 | expectedConfig.Auth.KeyboardInteractiveAuth.Questions = []keyboardInteractiveAuthQuestion{ 242 | {"q1", true}, 243 | {"q2", false}, 244 | } 245 | expectedConfig.SSHProto.Version = "SSH-2.0-test" 246 | expectedConfig.SSHProto.RekeyThreshold = 123 247 | expectedConfig.SSHProto.KeyExchanges = []string{"kex"} 248 | expectedConfig.SSHProto.Ciphers = []string{"cipher"} 249 | expectedConfig.SSHProto.MACs = []string{"mac"} 250 | verifyConfig(t, cfg, expectedConfig) 251 | verifyDefaultKeys(t, dataDir) 252 | } 253 | 254 | func TestUserConfigCustomKeysAndServices(t *testing.T) { 255 | keyFile, err := generateKey(t.TempDir(), ecdsa_key) 256 | if err != nil { 257 | t.Fatalf("Failed to generate key: %v", err) 258 | } 259 | dataDir := t.TempDir() 260 | cfgString := fmt.Sprintf(` 261 | server: 262 | host_keys: [%v] 263 | tcpip_services: 264 | 8080: HTTP 265 | `, keyFile) 266 | cfg := &config{} 267 | err = cfg.load(cfgString, dataDir) 268 | if err != nil { 269 | t.Fatalf("Failed to get config: %v", err) 270 | } 271 | expectedConfig := &config{} 272 | expectedConfig.Server.ListenAddress = "127.0.0.1:2022" 273 | expectedConfig.Server.HostKeys = []string{keyFile} 274 | expectedConfig.Server.TCPIPServices = map[uint32]string{ 275 | 8080: "HTTP", 276 | } 277 | expectedConfig.Logging.Timestamps = true 278 | expectedConfig.Auth.PasswordAuth.Enabled = true 279 | expectedConfig.Auth.PasswordAuth.Accepted = true 280 | expectedConfig.Auth.PublicKeyAuth.Enabled = true 281 | expectedConfig.SSHProto.Version = "SSH-2.0-sshesame" 282 | expectedConfig.SSHProto.Banner = "This is an SSH honeypot. Everything is logged and monitored." 283 | verifyConfig(t, cfg, expectedConfig) 284 | files, err := os.ReadDir(dataDir) 285 | if err != nil { 286 | t.Fatalf("Failed to read directory: %v", err) 287 | } 288 | if len(files) != 0 { 289 | t.Errorf("files=%v, want []", files) 290 | } 291 | } 292 | 293 | func TestSetupLoggingOldHandleClosed(t *testing.T) { 294 | file := &mockFile{} 295 | cfg := &config{logFileHandle: file} 296 | if err := cfg.setupLogging(); err != nil { 297 | t.Fatalf("Failed to set up logging: %v", err) 298 | } 299 | if !file.closed { 300 | t.Errorf("file.closed=false, want true") 301 | } 302 | } 303 | 304 | func TestLogReloadSameFile(t *testing.T) { 305 | cfg := &config{} 306 | tempDir := t.TempDir() 307 | cfg.Logging.File = path.Join(tempDir, "test.log") 308 | if err := cfg.setupLogging(); err != nil { 309 | t.Fatalf("Failed to set up logging: %v", err) 310 | } 311 | log.Printf("test1") 312 | if err := cfg.setupLogging(); err != nil { 313 | t.Fatalf("Failed to set up logging: %v", err) 314 | } 315 | log.Printf("test2") 316 | if err := cfg.logFileHandle.Close(); err != nil { 317 | t.Fatalf("Failed to close log file: %v", err) 318 | } 319 | logs, err := os.ReadFile(cfg.Logging.File) 320 | if err != nil { 321 | t.Fatalf("Failed to read log file: %v", err) 322 | } 323 | expectedLogs := "test1\ntest2\n" 324 | if string(logs) != expectedLogs { 325 | t.Errorf("logs=%v, want %v", string(logs), expectedLogs) 326 | } 327 | } 328 | 329 | func TestLogReloadDifferentFile(t *testing.T) { 330 | cfg := &config{} 331 | tempDir := t.TempDir() 332 | logFile1 := path.Join(tempDir, "test1.log") 333 | cfg.Logging.File = logFile1 334 | if err := cfg.setupLogging(); err != nil { 335 | t.Fatalf("Failed to set up logging: %v", err) 336 | } 337 | log.Printf("test1") 338 | logFile2 := path.Join(tempDir, "test2.log") 339 | cfg.Logging.File = logFile2 340 | if err := cfg.setupLogging(); err != nil { 341 | t.Fatalf("Failed to set up logging: %v", err) 342 | } 343 | log.Printf("test2") 344 | if err := cfg.logFileHandle.Close(); err != nil { 345 | t.Fatalf("Failed to close log file: %v", err) 346 | } 347 | logs1, err := os.ReadFile(logFile1) 348 | if err != nil { 349 | t.Fatalf("Failed to read log file: %v", err) 350 | } 351 | expectedLogs1 := "test1\n" 352 | if string(logs1) != expectedLogs1 { 353 | t.Errorf("logs1=%v, want %v", string(logs1), expectedLogs1) 354 | } 355 | logs2, err := os.ReadFile(logFile2) 356 | if err != nil { 357 | t.Fatalf("Failed to read log file: %v", err) 358 | } 359 | expectedLogs2 := "test2\n" 360 | if string(logs2) != expectedLogs2 { 361 | t.Errorf("logs2=%v, want %v", string(logs2), expectedLogs2) 362 | } 363 | } 364 | 365 | func TestExistingKey(t *testing.T) { 366 | dataDir := path.Join(t.TempDir(), "keys") 367 | oldKeyFile, err := generateKey(dataDir, ed25519_key) 368 | if err != nil { 369 | t.Fatalf("Failed to generate key: %v", err) 370 | } 371 | oldKey, err := os.ReadFile(oldKeyFile) 372 | if err != nil { 373 | t.Fatalf("Failed to read key: %v", err) 374 | } 375 | newKeyFile, err := generateKey(dataDir, ed25519_key) 376 | if err != nil { 377 | t.Fatalf("Failed to generate key: %v", err) 378 | } 379 | newKey, err := os.ReadFile(newKeyFile) 380 | if err != nil { 381 | t.Fatalf("Failed to read key: %v", err) 382 | } 383 | if !reflect.DeepEqual(oldKey, newKey) { 384 | t.Errorf("oldKey!=newKey") 385 | } 386 | } 387 | 388 | func TestDefaultConfigFile(t *testing.T) { 389 | configBytes, err := os.ReadFile("sshesame.yaml") 390 | if err != nil { 391 | t.Fatalf("Failed to read config file: %v", err) 392 | } 393 | cfg := &config{} 394 | if err := yaml.UnmarshalStrict(configBytes, cfg); err != nil { 395 | t.Fatalf("Failed to unmarshal config: %v", err) 396 | } 397 | dataDir := t.TempDir() 398 | writeTestKeys(t, dataDir) 399 | if err := cfg.setDefaultHostKeys(dataDir, []keySignature{rsa_key, ecdsa_key, ed25519_key}); err != nil { 400 | t.Fatalf("Failed to set default host keys: %v", err) 401 | } 402 | if err := cfg.setupSSHConfig(); err != nil { 403 | t.Fatalf("Failed to setup SSH config: %v", err) 404 | } 405 | 406 | // The sample config has example keyboard interactive auth options set. 407 | // Since the auth method itself is disabled, this doesn't make a difference. 408 | // Unset them so they don't affect the comparison. 409 | cfg.Auth.KeyboardInteractiveAuth.Instruction = "" 410 | cfg.Auth.KeyboardInteractiveAuth.Questions = nil 411 | 412 | writeTestKeys(t, dataDir) 413 | defaultCfg := &config{} 414 | err = defaultCfg.load("", dataDir) 415 | if err != nil { 416 | t.Fatalf("Failed to get default config: %v", err) 417 | } 418 | verifyConfig(t, cfg, defaultCfg) 419 | } 420 | 421 | func TestUnspecifiedHostKeys(t *testing.T) { 422 | cfgString := ` 423 | server: 424 | host_keys: null 425 | ` 426 | dataDir := t.TempDir() 427 | writeTestKeys(t, dataDir) 428 | cfg := &config{} 429 | if err := cfg.load(cfgString, dataDir); err != nil { 430 | t.Fatalf("Failed to get config: %v", err) 431 | } 432 | if len(cfg.parsedHostKeys) != 3 { 433 | t.Errorf("len(cfg.parsedHostKeys)=%d, want 3", len(cfg.parsedHostKeys)) 434 | } 435 | } 436 | 437 | func TestEmptyHostKeys(t *testing.T) { 438 | cfgString := ` 439 | server: 440 | host_keys: [] 441 | ` 442 | dataDir := t.TempDir() 443 | writeTestKeys(t, dataDir) 444 | cfg := &config{} 445 | if err := cfg.load(cfgString, dataDir); err != nil { 446 | t.Fatalf("Failed to get config: %v", err) 447 | } 448 | if len(cfg.parsedHostKeys) != 3 { 449 | t.Errorf("len(cfg.parsedHostKeys)=%d, want 3", len(cfg.parsedHostKeys)) 450 | } 451 | } 452 | 453 | func TestUnspecifiedTCPIPServices(t *testing.T) { 454 | cfgString := ` 455 | server: 456 | tcpip_services: null 457 | ` 458 | dataDir := t.TempDir() 459 | writeTestKeys(t, dataDir) 460 | cfg := &config{} 461 | if err := cfg.load(cfgString, dataDir); err != nil { 462 | t.Fatalf("Failed to get config: %v", err) 463 | } 464 | if len(cfg.Server.TCPIPServices) == 0 { 465 | t.Errorf("len(cfg.Server.TCPIPServices)=%d, want >0", len(cfg.Server.TCPIPServices)) 466 | } 467 | } 468 | 469 | func TestEmptyTCPIPServices(t *testing.T) { 470 | cfgString := ` 471 | server: 472 | tcpip_services: {} 473 | ` 474 | dataDir := t.TempDir() 475 | writeTestKeys(t, dataDir) 476 | cfg := &config{} 477 | if err := cfg.load(cfgString, dataDir); err != nil { 478 | t.Fatalf("Failed to get config: %v", err) 479 | } 480 | if len(cfg.Server.TCPIPServices) != 0 { 481 | t.Errorf("len(cfg.Server.TCPIPServices)=%d, want 0", len(cfg.Server.TCPIPServices)) 482 | } 483 | } 484 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/jaksi/sshutils" 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | type connContext struct { 13 | ssh.ConnMetadata 14 | cfg *config 15 | noMoreSessions bool 16 | } 17 | 18 | type channelContext struct { 19 | connContext 20 | channelID int 21 | } 22 | 23 | var channelHandlers = map[string]func(newChannel ssh.NewChannel, context channelContext) error{ 24 | "session": handleSessionChannel, 25 | "direct-tcpip": handleDirectTCPIPChannel, 26 | } 27 | 28 | var ( 29 | sshConnectionsMetric = promauto.NewCounter(prometheus.CounterOpts{ 30 | Name: "sshesame_ssh_connections_total", 31 | Help: "Total number of SSH connections", 32 | }) 33 | activeSSHConnectionsMetric = promauto.NewGauge(prometheus.GaugeOpts{ 34 | Name: "sshesame_active_ssh_connections", 35 | Help: "Number of active SSH connections", 36 | }) 37 | unknownChannelsMetric = promauto.NewCounter(prometheus.CounterOpts{ 38 | Name: "sshesame_unknown_channels_total", 39 | Help: "Total number of unknown channels", 40 | }) 41 | ) 42 | 43 | func handleConnection(conn *sshutils.Conn, cfg *config) { 44 | sshConnectionsMetric.Inc() 45 | activeSSHConnectionsMetric.Inc() 46 | defer activeSSHConnectionsMetric.Dec() 47 | var channels sync.WaitGroup 48 | context := connContext{ConnMetadata: conn, cfg: cfg} 49 | defer func() { 50 | conn.Close() 51 | channels.Wait() 52 | context.logEvent(connectionCloseLog{}) 53 | }() 54 | 55 | context.logEvent(connectionLog{ 56 | ClientVersion: string(conn.ClientVersion()), 57 | }) 58 | 59 | hostKeysPayload := make([][]byte, len(cfg.parsedHostKeys)) 60 | for i, key := range cfg.parsedHostKeys { 61 | hostKeysPayload[i] = key.PublicKey().Marshal() 62 | } 63 | if _, _, err := conn.SendRequest("hostkeys-00@openssh.com", false, marshalBytes(hostKeysPayload)); err != nil { 64 | warningLogger.Printf("Failed to send hostkeys-00@openssh.com request: %v", err) 65 | return 66 | } 67 | 68 | channelID := 0 69 | for conn.Requests != nil || conn.NewChannels != nil { 70 | select { 71 | case request, ok := <-conn.Requests: 72 | if !ok { 73 | conn.Requests = nil 74 | continue 75 | } 76 | context.logEvent(debugGlobalRequestLog{ 77 | RequestType: request.Type, 78 | WantReply: request.WantReply, 79 | Payload: string(request.Payload), 80 | }) 81 | if err := handleGlobalRequest(request, &context); err != nil { 82 | warningLogger.Printf("Failed to handle global request: %v", err) 83 | conn.Requests = nil 84 | continue 85 | } 86 | case newChannel, ok := <-conn.NewChannels: 87 | if !ok { 88 | conn.NewChannels = nil 89 | continue 90 | } 91 | context.logEvent(debugChannelLog{ 92 | channelLog: channelLog{ChannelID: channelID}, 93 | ChannelType: newChannel.ChannelType(), 94 | ExtraData: string(newChannel.ExtraData()), 95 | }) 96 | channelType := newChannel.ChannelType() 97 | handler := channelHandlers[channelType] 98 | if handler == nil { 99 | unknownChannelsMetric.Inc() 100 | warningLogger.Printf("Unsupported channel type %v", channelType) 101 | if err := newChannel.Reject(ssh.ConnectionFailed, "open failed"); err != nil { 102 | warningLogger.Printf("Failed to reject channel: %v", err) 103 | conn.NewChannels = nil 104 | continue 105 | } 106 | continue 107 | } 108 | channels.Add(1) 109 | go func(context channelContext) { 110 | defer channels.Done() 111 | if err := handler(newChannel, context); err != nil { 112 | warningLogger.Printf("Failed to handle new channel: %v", err) 113 | conn.Close() 114 | } 115 | }(channelContext{context, channelID}) 116 | channelID++ 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jaksi/sshesame 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/adrg/xdg v0.5.0 7 | github.com/jaksi/sshutils v0.0.13 8 | github.com/prometheus/client_golang v1.19.1 9 | golang.org/x/crypto v0.25.0 10 | golang.org/x/term v0.22.0 11 | gopkg.in/yaml.v2 v2.4.0 12 | ) 13 | 14 | require ( 15 | github.com/beorn7/perks v1.0.1 // indirect 16 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 17 | github.com/kr/text v0.2.0 // indirect 18 | github.com/prometheus/client_model v0.5.0 // indirect 19 | github.com/prometheus/common v0.48.0 // indirect 20 | github.com/prometheus/procfs v0.12.0 // indirect 21 | golang.org/x/sys v0.22.0 // indirect 22 | google.golang.org/protobuf v1.33.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= 2 | github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/jaksi/sshutils v0.0.13 h1:0XKYoXU4xzeur8q5uCAerjkcLh9DaEe9OQOhVKuSSJ0= 13 | github.com/jaksi/sshutils v0.0.13/go.mod h1:H1/OsmZrqUwTydEeQVT4cTMXO7IDUDb2ClYvGQNg5Ss= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 21 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 22 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 23 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 24 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 25 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 26 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 27 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 28 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 29 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 30 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 31 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 32 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 33 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 34 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 35 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 37 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 38 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 39 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 42 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 43 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 44 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /logging.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type logEntry interface { 14 | fmt.Stringer 15 | eventType() string 16 | } 17 | 18 | type addressLog struct { 19 | Host string `json:"host"` 20 | Port int `json:"port"` 21 | } 22 | 23 | func (entry addressLog) String() string { 24 | return net.JoinHostPort(entry.Host, fmt.Sprint(entry.Port)) 25 | } 26 | 27 | func getAddressLog(host string, port int, cfg *config) interface{} { 28 | entry := addressLog{ 29 | Host: host, 30 | Port: port, 31 | } 32 | if cfg.Logging.SplitHostPort { 33 | return entry 34 | } 35 | return entry.String() 36 | } 37 | 38 | type authAccepted bool 39 | 40 | func (accepted authAccepted) String() string { 41 | if accepted { 42 | return "accepted" 43 | } 44 | return "rejected" 45 | } 46 | 47 | type authLog struct { 48 | User string `json:"user"` 49 | Accepted authAccepted `json:"accepted"` 50 | } 51 | 52 | type noAuthLog struct { 53 | authLog 54 | } 55 | 56 | func (entry noAuthLog) String() string { 57 | return fmt.Sprintf("authentication for user %q without credentials %v", entry.User, entry.Accepted) 58 | } 59 | func (entry noAuthLog) eventType() string { 60 | return "no_auth" 61 | } 62 | 63 | type passwordAuthLog struct { 64 | authLog 65 | Password string `json:"password"` 66 | } 67 | 68 | func (entry passwordAuthLog) String() string { 69 | return fmt.Sprintf("authentication for user %q with password %q %v", entry.User, entry.Password, entry.Accepted) 70 | } 71 | func (entry passwordAuthLog) eventType() string { 72 | return "password_auth" 73 | } 74 | 75 | type publicKeyAuthLog struct { 76 | authLog 77 | PublicKeyFingerprint string `json:"public_key"` 78 | } 79 | 80 | func (entry publicKeyAuthLog) String() string { 81 | return fmt.Sprintf("authentication for user %q with public key %q %v", entry.User, entry.PublicKeyFingerprint, entry.Accepted) 82 | } 83 | func (entry publicKeyAuthLog) eventType() string { 84 | return "public_key_auth" 85 | } 86 | 87 | type keyboardInteractiveAuthLog struct { 88 | authLog 89 | Answers []string `json:"answers"` 90 | } 91 | 92 | func (entry keyboardInteractiveAuthLog) String() string { 93 | return fmt.Sprintf("authentication for user %q with keyboard interactive answers %q %v", entry.User, entry.Answers, entry.Accepted) 94 | } 95 | func (entry keyboardInteractiveAuthLog) eventType() string { 96 | return "keyboard_interactive_auth" 97 | } 98 | 99 | type connectionLog struct { 100 | ClientVersion string `json:"client_version"` 101 | } 102 | 103 | func (entry connectionLog) String() string { 104 | return fmt.Sprintf("connection with client version %q established", entry.ClientVersion) 105 | } 106 | func (entry connectionLog) eventType() string { 107 | return "connection" 108 | } 109 | 110 | type connectionCloseLog struct { 111 | } 112 | 113 | func (entry connectionCloseLog) String() string { 114 | return "connection closed" 115 | } 116 | func (entry connectionCloseLog) eventType() string { 117 | return "connection_close" 118 | } 119 | 120 | type tcpipForwardLog struct { 121 | Address interface{} `json:"address"` 122 | } 123 | 124 | func (entry tcpipForwardLog) String() string { 125 | return fmt.Sprintf("TCP/IP forwarding on %v requested", entry.Address) 126 | } 127 | func (entry tcpipForwardLog) eventType() string { 128 | return "tcpip_forward" 129 | } 130 | 131 | type cancelTCPIPForwardLog struct { 132 | Address interface{} `json:"address"` 133 | } 134 | 135 | func (entry cancelTCPIPForwardLog) String() string { 136 | return fmt.Sprintf("TCP/IP forwarding on %v canceled", entry.Address) 137 | } 138 | func (entry cancelTCPIPForwardLog) eventType() string { 139 | return "cancel_tcpip_forward" 140 | } 141 | 142 | type noMoreSessionsLog struct { 143 | } 144 | 145 | func (entry noMoreSessionsLog) String() string { 146 | return "rejection of further session channels requested" 147 | } 148 | func (entry noMoreSessionsLog) eventType() string { 149 | return "no_more_sessions" 150 | } 151 | 152 | type hostKeysProveLog struct { 153 | HostKeyFiles []string `json:"host_key_files"` 154 | } 155 | 156 | func (entry hostKeysProveLog) String() string { 157 | baseNames := make([]string, len(entry.HostKeyFiles)) 158 | for i, hostKeyFile := range entry.HostKeyFiles { 159 | baseNames[i] = fmt.Sprintf("%q", filepath.Base(hostKeyFile)) 160 | } 161 | return fmt.Sprintf("proof of ownership of host keys %v requested", strings.Join(baseNames, ", ")) 162 | } 163 | func (entry hostKeysProveLog) eventType() string { 164 | return "host_keys_prove" 165 | } 166 | 167 | type channelLog struct { 168 | ChannelID int `json:"channel_id"` 169 | } 170 | 171 | type sessionLog struct { 172 | channelLog 173 | } 174 | 175 | func (entry sessionLog) String() string { 176 | return fmt.Sprintf("[channel %v] session requested", entry.ChannelID) 177 | } 178 | func (entry sessionLog) eventType() string { 179 | return "session" 180 | } 181 | 182 | type sessionCloseLog struct { 183 | channelLog 184 | } 185 | 186 | func (entry sessionCloseLog) String() string { 187 | return fmt.Sprintf("[channel %v] closed", entry.ChannelID) 188 | } 189 | func (entry sessionCloseLog) eventType() string { 190 | return "session_close" 191 | } 192 | 193 | type sessionInputLog struct { 194 | channelLog 195 | Input string `json:"input"` 196 | } 197 | 198 | func (entry sessionInputLog) String() string { 199 | return fmt.Sprintf("[channel %v] input: %q", entry.ChannelID, entry.Input) 200 | } 201 | func (entry sessionInputLog) eventType() string { 202 | return "session_input" 203 | } 204 | 205 | type directTCPIPLog struct { 206 | channelLog 207 | From interface{} `json:"from"` 208 | To interface{} `json:"to"` 209 | } 210 | 211 | func (entry directTCPIPLog) String() string { 212 | return fmt.Sprintf("[channel %v] direct TCP/IP forwarding from %v to %v requested", entry.ChannelID, entry.From, entry.To) 213 | } 214 | func (entry directTCPIPLog) eventType() string { 215 | return "direct_tcpip" 216 | } 217 | 218 | type directTCPIPCloseLog struct { 219 | channelLog 220 | } 221 | 222 | func (entry directTCPIPCloseLog) String() string { 223 | return fmt.Sprintf("[channel %v] closed", entry.ChannelID) 224 | } 225 | func (entry directTCPIPCloseLog) eventType() string { 226 | return "direct_tcpip_close" 227 | } 228 | 229 | type directTCPIPInputLog struct { 230 | channelLog 231 | Input string `json:"input"` 232 | } 233 | 234 | func (entry directTCPIPInputLog) String() string { 235 | return fmt.Sprintf("[channel %v] input: %q", entry.ChannelID, entry.Input) 236 | } 237 | func (entry directTCPIPInputLog) eventType() string { 238 | return "direct_tcpip_input" 239 | } 240 | 241 | type ptyLog struct { 242 | channelLog 243 | Terminal string `json:"terminal"` 244 | Width uint32 `json:"width"` 245 | Height uint32 `json:"height"` 246 | } 247 | 248 | func (entry ptyLog) String() string { 249 | return fmt.Sprintf("[channel %v] PTY using terminal %q (size %vx%v) requested", entry.ChannelID, entry.Terminal, entry.Width, entry.Height) 250 | } 251 | func (entry ptyLog) eventType() string { 252 | return "pty" 253 | } 254 | 255 | type shellLog struct { 256 | channelLog 257 | } 258 | 259 | func (entry shellLog) String() string { 260 | return fmt.Sprintf("[channel %v] shell requested", entry.ChannelID) 261 | } 262 | func (entry shellLog) eventType() string { 263 | return "shell" 264 | } 265 | 266 | type execLog struct { 267 | channelLog 268 | Command string `json:"command"` 269 | } 270 | 271 | func (entry execLog) String() string { 272 | return fmt.Sprintf("[channel %v] command %q requested", entry.ChannelID, entry.Command) 273 | } 274 | func (entry execLog) eventType() string { 275 | return "exec" 276 | } 277 | 278 | type subsystemLog struct { 279 | channelLog 280 | Subsystem string `json:"subsystem"` 281 | } 282 | 283 | func (entry subsystemLog) String() string { 284 | return fmt.Sprintf("[channel %v] subsystem %q requested", entry.ChannelID, entry.Subsystem) 285 | } 286 | func (entry subsystemLog) eventType() string { 287 | return "subsystem" 288 | } 289 | 290 | type x11Log struct { 291 | channelLog 292 | Screen uint32 `json:"screen"` 293 | } 294 | 295 | func (entry x11Log) String() string { 296 | return fmt.Sprintf("[channel %v] X11 forwarding on screen %v requested", entry.ChannelID, entry.Screen) 297 | } 298 | func (entry x11Log) eventType() string { 299 | return "x11" 300 | } 301 | 302 | type envLog struct { 303 | channelLog 304 | Name string `json:"name"` 305 | Value string `json:"value"` 306 | } 307 | 308 | func (entry envLog) String() string { 309 | return fmt.Sprintf("[channel %v] environment variable %q with value %q requested", entry.ChannelID, entry.Name, entry.Value) 310 | } 311 | func (entry envLog) eventType() string { 312 | return "env" 313 | } 314 | 315 | type windowChangeLog struct { 316 | channelLog 317 | Width uint32 `json:"width"` 318 | Height uint32 `json:"height"` 319 | } 320 | 321 | func (entry windowChangeLog) String() string { 322 | return fmt.Sprintf("[channel %v] window size change to %vx%v requested", entry.ChannelID, entry.Width, entry.Height) 323 | } 324 | func (entry windowChangeLog) eventType() string { 325 | return "window_change" 326 | } 327 | 328 | type debugGlobalRequestLog struct { 329 | RequestType string `json:"request_type"` 330 | WantReply bool `json:"want_reply"` 331 | Payload string `json:"payload"` 332 | } 333 | 334 | func (entry debugGlobalRequestLog) String() string { 335 | jsonBytes, err := json.Marshal(entry) 336 | if err != nil { 337 | warningLogger.Printf("Failed to log event: %v", err) 338 | return "" 339 | } 340 | return fmt.Sprintf("DEBUG global request received: %v\n", string(jsonBytes)) 341 | } 342 | func (entry debugGlobalRequestLog) eventType() string { 343 | return "debug_global_request" 344 | } 345 | 346 | type debugChannelLog struct { 347 | channelLog 348 | ChannelType string `json:"channel_type"` 349 | ExtraData string `json:"extra_data"` 350 | } 351 | 352 | func (entry debugChannelLog) String() string { 353 | jsonBytes, err := json.Marshal(entry) 354 | if err != nil { 355 | warningLogger.Printf("Failed to log event: %v", err) 356 | return "" 357 | } 358 | return fmt.Sprintf("DEBUG new channel requested: %v\n", string(jsonBytes)) 359 | } 360 | func (entry debugChannelLog) eventType() string { 361 | return "debug_channel" 362 | } 363 | 364 | type debugChannelRequestLog struct { 365 | channelLog 366 | RequestType string `json:"request_type"` 367 | WantReply bool `json:"want_reply"` 368 | Payload string `json:"payload"` 369 | } 370 | 371 | func (entry debugChannelRequestLog) String() string { 372 | jsonBytes, err := json.Marshal(entry) 373 | if err != nil { 374 | warningLogger.Printf("Failed to log event: %v", err) 375 | return "" 376 | } 377 | return fmt.Sprintf("DEBUG channel request received: %v\n", string(jsonBytes)) 378 | } 379 | func (entry debugChannelRequestLog) eventType() string { 380 | return "debug_channel_request" 381 | } 382 | 383 | func (context connContext) logEvent(entry logEntry) { 384 | if strings.HasPrefix(entry.eventType(), "debug_") && !context.cfg.Logging.Debug { 385 | return 386 | } 387 | if context.cfg.Logging.JSON { 388 | var jsonEntry interface{} 389 | tcpSource := context.RemoteAddr().(*net.TCPAddr) 390 | source := getAddressLog(tcpSource.IP.String(), tcpSource.Port, context.cfg) 391 | if context.cfg.Logging.Timestamps { 392 | jsonEntry = struct { 393 | Time string `json:"time"` 394 | Source interface{} `json:"source"` 395 | EventType string `json:"event_type"` 396 | Event logEntry `json:"event"` 397 | }{time.Now().Format(time.RFC3339), source, entry.eventType(), entry} 398 | } else { 399 | jsonEntry = struct { 400 | Source interface{} `json:"source"` 401 | EventType string `json:"event_type"` 402 | Event logEntry `json:"event"` 403 | }{source, entry.eventType(), entry} 404 | } 405 | logBytes, err := json.Marshal(jsonEntry) 406 | if err != nil { 407 | warningLogger.Printf("Failed to log event: %v", err) 408 | return 409 | } 410 | log.Print(string(logBytes)) 411 | } else { 412 | log.Printf("[%v] %v", context.RemoteAddr().String(), entry) 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /logging_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "testing" 7 | ) 8 | 9 | type mockLogEntry struct { 10 | Content string `json:"content"` 11 | } 12 | 13 | func (entry mockLogEntry) String() string { 14 | return fmt.Sprintf("test %v", entry.Content) 15 | } 16 | 17 | func (mockLogEntry) eventType() string { 18 | return "test" 19 | } 20 | 21 | func testLogging(t *testing.T, cfg *loggingConfig, log logEntry, expectedLogs *regexp.Regexp) { 22 | t.Helper() 23 | c := &config{ 24 | Logging: *cfg, 25 | } 26 | logBuffer := setupLogBuffer(t, c) 27 | connContext{ConnMetadata: mockConnContext{}, cfg: c}.logEvent(log) 28 | logs := logBuffer.String() 29 | // Remove trailing newline 30 | logs = logs[:len(logs)-1] 31 | if !expectedLogs.MatchString(logs) { 32 | t.Errorf("logs=%v, want match for %v", logs, expectedLogs) 33 | } 34 | } 35 | 36 | func TestPlainWithTimestamps(t *testing.T) { 37 | testLogging(t, &loggingConfig{ 38 | JSON: false, 39 | Timestamps: true, 40 | }, mockLogEntry{"lorem"}, regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} \[127\.0\.0\.1:1234\] test lorem$`)) 41 | } 42 | 43 | func TestJSONWithTimestamps(t *testing.T) { 44 | testLogging(t, &loggingConfig{ 45 | JSON: true, 46 | Timestamps: true, 47 | }, mockLogEntry{"ipsum"}, regexp.MustCompile(`^{"time":"[^"]+","source":"127\.0\.0\.1:1234","event_type":"test","event":{"content":"ipsum"}}$`)) 48 | } 49 | 50 | func TestPlainWithoutTimestamps(t *testing.T) { 51 | testLogging(t, &loggingConfig{ 52 | JSON: false, 53 | Timestamps: false, 54 | }, mockLogEntry{"dolor"}, regexp.MustCompile(`^\[127\.0\.0\.1:1234\] test dolor$`)) 55 | } 56 | 57 | func TestJSONWithoutTimestamps(t *testing.T) { 58 | testLogging(t, &loggingConfig{ 59 | JSON: true, 60 | Timestamps: false, 61 | }, mockLogEntry{"sit"}, regexp.MustCompile(`^{"source":"127\.0\.0\.1:1234","event_type":"test","event":{"content":"sit"}}$`)) 62 | } 63 | 64 | func TestPlainWithAddressSplitting(t *testing.T) { 65 | testLogging(t, &loggingConfig{ 66 | JSON: false, 67 | SplitHostPort: true, 68 | }, mockLogEntry{"amet"}, regexp.MustCompile(`^\[127\.0\.0\.1:1234\] test amet$`)) 69 | } 70 | 71 | func TestJSONWithAddressSplitting(t *testing.T) { 72 | testLogging(t, &loggingConfig{ 73 | JSON: true, 74 | SplitHostPort: true, 75 | }, mockLogEntry{"consectetur"}, regexp.MustCompile(`^{"source":{"host":"127\.0\.0\.1","port":1234},"event_type":"test","event":{"content":"consectetur"}}$`)) 76 | } 77 | 78 | func TestPlainWithoutAddressSplitting(t *testing.T) { 79 | testLogging(t, &loggingConfig{ 80 | JSON: false, 81 | SplitHostPort: false, 82 | }, mockLogEntry{"adipiscing"}, regexp.MustCompile(`^\[127\.0\.0\.1:1234\] test adipiscing$`)) 83 | } 84 | 85 | func TestJSONWithoutAddressSplitting(t *testing.T) { 86 | testLogging(t, &loggingConfig{ 87 | JSON: true, 88 | SplitHostPort: false, 89 | }, mockLogEntry{"elit"}, regexp.MustCompile(`^{"source":"127\.0\.0\.1:1234","event_type":"test","event":{"content":"elit"}}$`)) 90 | } 91 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "syscall" 11 | 12 | "github.com/adrg/xdg" 13 | "github.com/jaksi/sshutils" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | ) 16 | 17 | var ( 18 | infoLogger *log.Logger 19 | warningLogger *log.Logger 20 | errorLogger *log.Logger 21 | ) 22 | 23 | func init() { 24 | infoLogger = log.New(os.Stderr, "INFO ", log.LstdFlags) 25 | warningLogger = log.New(os.Stderr, "WARNING ", log.LstdFlags) 26 | errorLogger = log.New(os.Stderr, "ERROR ", log.LstdFlags) 27 | } 28 | 29 | func main() { 30 | configFile := flag.String("config", "", "optional config file") 31 | dataDir := flag.String("data_dir", path.Join(xdg.DataHome, "sshesame"), "data directory to store automatically generated host keys in") 32 | flag.Parse() 33 | 34 | cfg := &config{} 35 | configString := "" 36 | if *configFile != "" { 37 | configBytes, err := os.ReadFile(*configFile) 38 | if err != nil { 39 | errorLogger.Fatalf("Failed to read config file: %v", err) 40 | } 41 | configString = string(configBytes) 42 | } 43 | err := cfg.load(configString, *dataDir) 44 | if err != nil { 45 | errorLogger.Fatalf("Failed to load config: %v", err) 46 | } 47 | reloadSignals := make(chan os.Signal, 1) 48 | defer close(reloadSignals) 49 | go func() { 50 | for signal := range reloadSignals { 51 | infoLogger.Printf("Reloading config due to %s", signal) 52 | configBytes, err := os.ReadFile(*configFile) 53 | if err != nil { 54 | warningLogger.Printf("Failed to read config file: %v", err) 55 | } 56 | configString = string(configBytes) 57 | err = cfg.load(configString, *dataDir) 58 | if err != nil { 59 | warningLogger.Printf("Failed to reload config: %v", err) 60 | } 61 | } 62 | }() 63 | signal.Notify(reloadSignals, syscall.SIGHUP) 64 | 65 | listener, err := sshutils.Listen(cfg.Server.ListenAddress, cfg.sshConfig) 66 | if err != nil { 67 | errorLogger.Fatalf("Failed to listen for connections: %v", err) 68 | } 69 | defer listener.Close() 70 | 71 | infoLogger.Printf("Listening on %v", listener.Addr()) 72 | 73 | if cfg.Logging.MetricsAddress != "" { 74 | http.Handle("/metrics", promhttp.Handler()) 75 | infoLogger.Printf("Serving metrics on %v", cfg.Logging.MetricsAddress) 76 | go func() { 77 | if err := http.ListenAndServe(cfg.Logging.MetricsAddress, nil); err != nil { 78 | errorLogger.Fatalf("Failed to serve metrics: %v", err) 79 | } 80 | }() 81 | } 82 | 83 | for { 84 | conn, err := listener.Accept() 85 | if err != nil { 86 | warningLogger.Printf("Failed to accept connection: %v", err) 87 | continue 88 | } 89 | go handleConnection(conn, cfg) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /openssh.yaml: -------------------------------------------------------------------------------- 1 | ssh_proto: 2 | version: SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.2 3 | banner: 4 | -------------------------------------------------------------------------------- /replay_tests/direct-tcpip.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "global_request", 7 | "entry": { 8 | "type": "no-more-sessions@openssh.com", 9 | "want_reply": false, 10 | "payload": "", 11 | "accepted": false, 12 | "response": "" 13 | } 14 | }, 15 | { 16 | "source": "server", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "hostkeys-00@openssh.com", 20 | "want_reply": false, 21 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "client", 28 | "type": "new_channel", 29 | "entry": { 30 | "type": "direct-tcpip", 31 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFAAAAAJMTI3LjAuMC4xAADhpg", 32 | "accepted": true, 33 | "reject_reason": 0, 34 | "message": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_data", 40 | "entry": { 41 | "channel_id": 0, 42 | "data": "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: curl/7.64.1\r\nAccept: */*\r\n\r\n" 43 | } 44 | }, 45 | { 46 | "source": "server", 47 | "type": "channel_data", 48 | "entry": { 49 | "channel_id": 0, 50 | "data": "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" 51 | } 52 | }, 53 | { 54 | "source": "client", 55 | "type": "channel_eof", 56 | "entry": { 57 | "channel_id": 0 58 | } 59 | }, 60 | { 61 | "source": "server", 62 | "type": "channel_close", 63 | "entry": { 64 | "channel_id": 0 65 | } 66 | }, 67 | { 68 | "source": "client", 69 | "type": "new_channel", 70 | "entry": { 71 | "type": "direct-tcpip", 72 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFAAAAAJMTI3LjAuMC4xAADhpg", 73 | "accepted": true, 74 | "reject_reason": 0, 75 | "message": "" 76 | } 77 | }, 78 | { 79 | "source": "client", 80 | "type": "channel_data", 81 | "entry": { 82 | "channel_id": 1, 83 | "data": "GET /path HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: curl/7.64.1\r\nAccept: */*\r\n\r\n" 84 | } 85 | }, 86 | { 87 | "source": "server", 88 | "type": "channel_data", 89 | "entry": { 90 | "channel_id": 1, 91 | "data": "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" 92 | } 93 | }, 94 | { 95 | "source": "client", 96 | "type": "channel_eof", 97 | "entry": { 98 | "channel_id": 1 99 | } 100 | }, 101 | { 102 | "source": "server", 103 | "type": "channel_close", 104 | "entry": { 105 | "channel_id": 1 106 | } 107 | }, 108 | { 109 | "source": "client", 110 | "type": "new_channel", 111 | "entry": { 112 | "type": "direct-tcpip", 113 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFEAAAAJMTI3LjAuMC4xAADbHg", 114 | "accepted": false, 115 | "reject_reason": 2, 116 | "message": "Connection refused" 117 | } 118 | }, 119 | { 120 | "source": "client", 121 | "type": "connection_close", 122 | "entry": {} 123 | } 124 | ], 125 | "plain_logs": [ 126 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 127 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 128 | "[SOURCE] rejection of further session channels requested", 129 | "[SOURCE] [channel 0] direct TCP/IP forwarding from 127.0.0.1:57766 to 127.0.0.1:80 requested", 130 | "[SOURCE] [channel 0] input: \"GET / HTTP/1.1\\r\\nHost: 127.0.0.1:8080\\r\\nAccept: */*\\r\\nUser-Agent: curl/7.64.1\\r\\n\\r\\n\"", 131 | "[SOURCE] [channel 0] closed", 132 | "[SOURCE] [channel 1] direct TCP/IP forwarding from 127.0.0.1:57766 to 127.0.0.1:80 requested", 133 | "[SOURCE] [channel 1] input: \"GET /path HTTP/1.1\\r\\nHost: 127.0.0.1:8080\\r\\nAccept: */*\\r\\nUser-Agent: curl/7.64.1\\r\\n\\r\\n\"", 134 | "[SOURCE] [channel 1] closed", 135 | "[SOURCE] connection closed" 136 | ], 137 | "json_logs": [ 138 | { 139 | "source": "SOURCE", 140 | "event_type": "no_auth", 141 | "event": { 142 | "user": "jaksi", 143 | "accepted": true 144 | } 145 | }, 146 | { 147 | "source": "SOURCE", 148 | "event_type": "connection", 149 | "event": { 150 | "client_version": "SSH-2.0-Go" 151 | } 152 | }, 153 | { 154 | "source": "SOURCE", 155 | "event_type": "no_more_sessions", 156 | "event": {} 157 | }, 158 | { 159 | "source": "SOURCE", 160 | "event_type": "direct_tcpip", 161 | "event": { 162 | "channel_id": 0, 163 | "from": "127.0.0.1:57766", 164 | "to": "127.0.0.1:80" 165 | } 166 | }, 167 | { 168 | "source": "SOURCE", 169 | "event_type": "direct_tcpip_input", 170 | "event": { 171 | "channel_id": 0, 172 | "input": "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nAccept: */*\r\nUser-Agent: curl/7.64.1\r\n\r\n" 173 | } 174 | }, 175 | { 176 | "source": "SOURCE", 177 | "event_type": "direct_tcpip_close", 178 | "event": { 179 | "channel_id": 0 180 | } 181 | }, 182 | { 183 | "source": "SOURCE", 184 | "event_type": "direct_tcpip", 185 | "event": { 186 | "channel_id": 1, 187 | "from": "127.0.0.1:57766", 188 | "to": "127.0.0.1:80" 189 | } 190 | }, 191 | { 192 | "source": "SOURCE", 193 | "event_type": "direct_tcpip_input", 194 | "event": { 195 | "channel_id": 1, 196 | "input": "GET /path HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nAccept: */*\r\nUser-Agent: curl/7.64.1\r\n\r\n" 197 | } 198 | }, 199 | { 200 | "source": "SOURCE", 201 | "event_type": "direct_tcpip_close", 202 | "event": { 203 | "channel_id": 1 204 | } 205 | }, 206 | { 207 | "source": "SOURCE", 208 | "event_type": "connection_close", 209 | "event": {} 210 | } 211 | ] 212 | } -------------------------------------------------------------------------------- /replay_tests/misc.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "client", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "tcpip-forward", 20 | "want_reply": true, 21 | "payload": "AAAACWxvY2FsaG9zdAAAAAA", 22 | "accepted": true, 23 | "response": "AACGDQ" 24 | } 25 | }, 26 | { 27 | "source": "client", 28 | "type": "channel_request", 29 | "entry": { 30 | "channel_id": 0, 31 | "type": "x11-req", 32 | "want_reply": true, 33 | "payload": "AAAAABJNSVQtTUFHSUMtQ09PS0lFLTEAAAAgZTU0MmJkOTA3MDY2M2JlNjRjZTBhOGE2N2Q3NTVlNWIAAAAA", 34 | "accepted": true 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "global_request", 40 | "entry": { 41 | "type": "tcpip-forward", 42 | "want_reply": true, 43 | "payload": "AAAACWxvY2FsaG9zdAAACSk", 44 | "accepted": true, 45 | "response": "" 46 | } 47 | }, 48 | { 49 | "source": "server", 50 | "type": "global_request", 51 | "entry": { 52 | "type": "hostkeys-00@openssh.com", 53 | "want_reply": false, 54 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 55 | "accepted": false, 56 | "response": "" 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "global_request", 62 | "entry": { 63 | "type": "no-more-sessions@openssh.com", 64 | "want_reply": false, 65 | "payload": "", 66 | "accepted": false, 67 | "response": "" 68 | } 69 | }, 70 | { 71 | "source": "client", 72 | "type": "channel_request", 73 | "entry": { 74 | "channel_id": 0, 75 | "type": "pty-req", 76 | "want_reply": true, 77 | "payload": "AAAADnh0ZXJtLTI1NmNvbG9yAAAAUAAAABYAAALQAAABjAAAAQCBAAAlgIAAACWAAQAAAAMCAAAAHAMAAAB/BAAAABUFAAAABAYAAAD/BwAAAP8IAAAAEQkAAAATCgAAABoLAAAAGQwAAAASDQAAABcOAAAAFhEAAAAUEgAAAA8eAAAAAB8AAAAAIAAAAAAhAAAAACIAAAAAIwAAAAAkAAAAASYAAAAAJwAAAAEoAAAAACkAAAABKgAAAAEyAAAAATMAAAABNQAAAAE2AAAAATcAAAAAOAAAAAA5AAAAADoAAAAAOwAAAAE8AAAAAT0AAAABPgAAAAFGAAAAAUgAAAABSQAAAABKAAAAAEsAAAAAWgAAAAFbAAAAAVwAAAAAXQAAAAAA", 78 | "accepted": true 79 | } 80 | }, 81 | { 82 | "source": "client", 83 | "type": "channel_request", 84 | "entry": { 85 | "channel_id": 0, 86 | "type": "env", 87 | "want_reply": false, 88 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 89 | "accepted": false 90 | } 91 | }, 92 | { 93 | "source": "client", 94 | "type": "channel_request", 95 | "entry": { 96 | "channel_id": 0, 97 | "type": "shell", 98 | "want_reply": true, 99 | "payload": "", 100 | "accepted": true 101 | } 102 | }, 103 | { 104 | "source": "server", 105 | "type": "channel_data", 106 | "entry": { 107 | "channel_id": 0, 108 | "data": "$ " 109 | } 110 | }, 111 | { 112 | "source": "client", 113 | "type": "channel_request", 114 | "entry": { 115 | "channel_id": 0, 116 | "type": "window-change", 117 | "want_reply": false, 118 | "payload": "AAAAUAAAABcAAALQAAABng", 119 | "accepted": false 120 | } 121 | }, 122 | { 123 | "source": "client", 124 | "type": "new_channel", 125 | "entry": { 126 | "type": "direct-tcpip", 127 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFAAAAAJMTI3LjAuMC4xAADhpg", 128 | "accepted": true, 129 | "reject_reason": 0, 130 | "message": "" 131 | } 132 | }, 133 | { 134 | "source": "client", 135 | "type": "channel_data", 136 | "entry": { 137 | "channel_id": 1, 138 | "data": "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: curl/7.64.1\r\nAccept: */*\r\n\r\n" 139 | } 140 | }, 141 | { 142 | "source": "server", 143 | "type": "channel_data", 144 | "entry": { 145 | "channel_id": 1, 146 | "data": "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" 147 | } 148 | }, 149 | { 150 | "source": "client", 151 | "type": "channel_eof", 152 | "entry": { 153 | "channel_id": 1 154 | } 155 | }, 156 | { 157 | "source": "server", 158 | "type": "channel_close", 159 | "entry": { 160 | "channel_id": 1 161 | } 162 | }, 163 | { 164 | "source": "client", 165 | "type": "new_channel", 166 | "entry": { 167 | "type": "direct-tcpip", 168 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFAAAAAJMTI3LjAuMC4xAADhpg", 169 | "accepted": true, 170 | "reject_reason": 0, 171 | "message": "" 172 | } 173 | }, 174 | { 175 | "source": "client", 176 | "type": "channel_data", 177 | "entry": { 178 | "channel_id": 2, 179 | "data": "GET /path HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: curl/7.64.1\r\nAccept: */*\r\n\r\n" 180 | } 181 | }, 182 | { 183 | "source": "server", 184 | "type": "channel_data", 185 | "entry": { 186 | "channel_id": 2, 187 | "data": "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" 188 | } 189 | }, 190 | { 191 | "source": "client", 192 | "type": "channel_eof", 193 | "entry": { 194 | "channel_id": 2 195 | } 196 | }, 197 | { 198 | "source": "server", 199 | "type": "channel_close", 200 | "entry": { 201 | "channel_id": 2 202 | } 203 | }, 204 | { 205 | "source": "client", 206 | "type": "new_channel", 207 | "entry": { 208 | "type": "direct-tcpip", 209 | "extra_data": "AAAACTEyNy4wLjAuMQAAAFEAAAAJMTI3LjAuMC4xAADgCQ", 210 | "accepted": false, 211 | "reject_reason": 2, 212 | "message": "Connection refused" 213 | } 214 | }, 215 | { 216 | "source": "client", 217 | "type": "channel_data", 218 | "entry": { 219 | "channel_id": 0, 220 | "data": "e" 221 | } 222 | }, 223 | { 224 | "source": "server", 225 | "type": "channel_data", 226 | "entry": { 227 | "channel_id": 0, 228 | "data": "e" 229 | } 230 | }, 231 | { 232 | "source": "client", 233 | "type": "channel_data", 234 | "entry": { 235 | "channel_id": 0, 236 | "data": "x" 237 | } 238 | }, 239 | { 240 | "source": "server", 241 | "type": "channel_data", 242 | "entry": { 243 | "channel_id": 0, 244 | "data": "x" 245 | } 246 | }, 247 | { 248 | "source": "client", 249 | "type": "channel_data", 250 | "entry": { 251 | "channel_id": 0, 252 | "data": "i" 253 | } 254 | }, 255 | { 256 | "source": "server", 257 | "type": "channel_data", 258 | "entry": { 259 | "channel_id": 0, 260 | "data": "i" 261 | } 262 | }, 263 | { 264 | "source": "client", 265 | "type": "channel_data", 266 | "entry": { 267 | "channel_id": 0, 268 | "data": "t" 269 | } 270 | }, 271 | { 272 | "source": "server", 273 | "type": "channel_data", 274 | "entry": { 275 | "channel_id": 0, 276 | "data": "t" 277 | } 278 | }, 279 | { 280 | "source": "client", 281 | "type": "channel_data", 282 | "entry": { 283 | "channel_id": 0, 284 | "data": " " 285 | } 286 | }, 287 | { 288 | "source": "server", 289 | "type": "channel_data", 290 | "entry": { 291 | "channel_id": 0, 292 | "data": " " 293 | } 294 | }, 295 | { 296 | "source": "client", 297 | "type": "channel_data", 298 | "entry": { 299 | "channel_id": 0, 300 | "data": "4" 301 | } 302 | }, 303 | { 304 | "source": "server", 305 | "type": "channel_data", 306 | "entry": { 307 | "channel_id": 0, 308 | "data": "4" 309 | } 310 | }, 311 | { 312 | "source": "client", 313 | "type": "channel_data", 314 | "entry": { 315 | "channel_id": 0, 316 | "data": "2" 317 | } 318 | }, 319 | { 320 | "source": "server", 321 | "type": "channel_data", 322 | "entry": { 323 | "channel_id": 0, 324 | "data": "2" 325 | } 326 | }, 327 | { 328 | "source": "client", 329 | "type": "channel_data", 330 | "entry": { 331 | "channel_id": 0, 332 | "data": "\r" 333 | } 334 | }, 335 | { 336 | "source": "server", 337 | "type": "channel_data", 338 | "entry": { 339 | "channel_id": 0, 340 | "data": "\r\n" 341 | } 342 | }, 343 | { 344 | "source": "server", 345 | "type": "channel_request", 346 | "entry": { 347 | "channel_id": 0, 348 | "type": "exit-status", 349 | "want_reply": false, 350 | "payload": "AAAAKg", 351 | "accepted": false 352 | } 353 | }, 354 | { 355 | "source": "server", 356 | "type": "channel_request", 357 | "entry": { 358 | "channel_id": 0, 359 | "type": "eow@openssh.com", 360 | "want_reply": false, 361 | "payload": "", 362 | "accepted": false 363 | } 364 | }, 365 | { 366 | "source": "server", 367 | "type": "channel_eof", 368 | "entry": { 369 | "channel_id": 0 370 | } 371 | }, 372 | { 373 | "source": "server", 374 | "type": "channel_close", 375 | "entry": { 376 | "channel_id": 0 377 | } 378 | }, 379 | { 380 | "source": "client", 381 | "type": "connection_close", 382 | "entry": {} 383 | } 384 | ], 385 | "plain_logs": [ 386 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 387 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 388 | "[SOURCE] [channel 0] session requested", 389 | "[SOURCE] TCP/IP forwarding on localhost:0 requested", 390 | "[SOURCE] [channel 0] X11 forwarding on screen 0 requested", 391 | "[SOURCE] TCP/IP forwarding on localhost:2345 requested", 392 | "[SOURCE] rejection of further session channels requested", 393 | "[SOURCE] [channel 0] PTY using terminal \"xterm-256color\" (size 80x22) requested", 394 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 395 | "[SOURCE] [channel 0] shell requested", 396 | "[SOURCE] [channel 0] window size change to 80x23 requested", 397 | "[SOURCE] [channel 1] direct TCP/IP forwarding from 127.0.0.1:57766 to 127.0.0.1:80 requested", 398 | "[SOURCE] [channel 1] input: \"GET / HTTP/1.1\\r\\nHost: 127.0.0.1:8080\\r\\nAccept: */*\\r\\nUser-Agent: curl/7.64.1\\r\\n\\r\\n\"", 399 | "[SOURCE] [channel 1] closed", 400 | "[SOURCE] [channel 2] direct TCP/IP forwarding from 127.0.0.1:57766 to 127.0.0.1:80 requested", 401 | "[SOURCE] [channel 2] input: \"GET /path HTTP/1.1\\r\\nHost: 127.0.0.1:8080\\r\\nAccept: */*\\r\\nUser-Agent: curl/7.64.1\\r\\n\\r\\n\"", 402 | "[SOURCE] [channel 2] closed", 403 | "[SOURCE] [channel 0] input: \"exit 42\"", 404 | "[SOURCE] [channel 0] closed", 405 | "[SOURCE] connection closed" 406 | ], 407 | "json_logs": [ 408 | { 409 | "source": "SOURCE", 410 | "event_type": "no_auth", 411 | "event": { 412 | "user": "jaksi", 413 | "accepted": true 414 | } 415 | }, 416 | { 417 | "source": "SOURCE", 418 | "event_type": "connection", 419 | "event": { 420 | "client_version": "SSH-2.0-Go" 421 | } 422 | }, 423 | { 424 | "source": "SOURCE", 425 | "event_type": "session", 426 | "event": { 427 | "channel_id": 0 428 | } 429 | }, 430 | { 431 | "source": "SOURCE", 432 | "event_type": "tcpip_forward", 433 | "event": { 434 | "address": "localhost:0" 435 | } 436 | }, 437 | { 438 | "source": "SOURCE", 439 | "event_type": "x11", 440 | "event": { 441 | "channel_id": 0, 442 | "screen": 0 443 | } 444 | }, 445 | { 446 | "source": "SOURCE", 447 | "event_type": "tcpip_forward", 448 | "event": { 449 | "address": "localhost:2345" 450 | } 451 | }, 452 | { 453 | "source": "SOURCE", 454 | "event_type": "no_more_sessions", 455 | "event": {} 456 | }, 457 | { 458 | "source": "SOURCE", 459 | "event_type": "pty", 460 | "event": { 461 | "channel_id": 0, 462 | "terminal": "xterm-256color", 463 | "width": 80, 464 | "height": 22 465 | } 466 | }, 467 | { 468 | "source": "SOURCE", 469 | "event_type": "env", 470 | "event": { 471 | "channel_id": 0, 472 | "name": "LANG", 473 | "value": "en_IE.UTF-8" 474 | } 475 | }, 476 | { 477 | "source": "SOURCE", 478 | "event_type": "shell", 479 | "event": { 480 | "channel_id": 0 481 | } 482 | }, 483 | { 484 | "source": "SOURCE", 485 | "event_type": "window_change", 486 | "event": { 487 | "channel_id": 0, 488 | "width": 80, 489 | "height": 23 490 | } 491 | }, 492 | { 493 | "source": "SOURCE", 494 | "event_type": "direct_tcpip", 495 | "event": { 496 | "channel_id": 1, 497 | "from": "127.0.0.1:57766", 498 | "to": "127.0.0.1:80" 499 | } 500 | }, 501 | { 502 | "source": "SOURCE", 503 | "event_type": "direct_tcpip_input", 504 | "event": { 505 | "channel_id": 1, 506 | "input": "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nAccept: */*\r\nUser-Agent: curl/7.64.1\r\n\r\n" 507 | } 508 | }, 509 | { 510 | "source": "SOURCE", 511 | "event_type": "direct_tcpip_close", 512 | "event": { 513 | "channel_id": 1 514 | } 515 | }, 516 | { 517 | "source": "SOURCE", 518 | "event_type": "direct_tcpip", 519 | "event": { 520 | "channel_id": 2, 521 | "from": "127.0.0.1:57766", 522 | "to": "127.0.0.1:80" 523 | } 524 | }, 525 | { 526 | "source": "SOURCE", 527 | "event_type": "direct_tcpip_input", 528 | "event": { 529 | "channel_id": 2, 530 | "input": "GET /path HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nAccept: */*\r\nUser-Agent: curl/7.64.1\r\n\r\n" 531 | } 532 | }, 533 | { 534 | "source": "SOURCE", 535 | "event_type": "direct_tcpip_close", 536 | "event": { 537 | "channel_id": 2 538 | } 539 | }, 540 | { 541 | "source": "SOURCE", 542 | "event_type": "session_input", 543 | "event": { 544 | "channel_id": 0, 545 | "input": "exit 42" 546 | } 547 | }, 548 | { 549 | "source": "SOURCE", 550 | "event_type": "session_close", 551 | "event": { 552 | "channel_id": 0 553 | } 554 | }, 555 | { 556 | "source": "SOURCE", 557 | "event_type": "connection_close", 558 | "event": {} 559 | } 560 | ] 561 | } -------------------------------------------------------------------------------- /replay_tests/pty_exec.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "server", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "hostkeys-00@openssh.com", 20 | "want_reply": false, 21 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "client", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "no-more-sessions@openssh.com", 31 | "want_reply": false, 32 | "payload": "", 33 | "accepted": false, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_request", 40 | "entry": { 41 | "channel_id": 0, 42 | "type": "pty-req", 43 | "want_reply": true, 44 | "payload": "AAAADnh0ZXJtLTI1NmNvbG9yAAAAngAAADAAAAWWAAADYgAAAQCBAAAlgIAAACWAAQAAAAMCAAAAHAMAAAB/BAAAABUFAAAABAYAAAD/BwAAAP8IAAAAEQkAAAATCgAAABoLAAAAGQwAAAASDQAAABcOAAAAFhEAAAAUEgAAAA8eAAAAAB8AAAAAIAAAAAAhAAAAACIAAAAAIwAAAAAkAAAAASYAAAAAJwAAAAEoAAAAACkAAAABKgAAAAEyAAAAATMAAAABNQAAAAE2AAAAATcAAAAAOAAAAAA5AAAAADoAAAAAOwAAAAE8AAAAAT0AAAABPgAAAAFGAAAAAUgAAAABSQAAAABKAAAAAEsAAAAAWgAAAAFbAAAAAVwAAAAAXQAAAAAA", 45 | "accepted": true 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "channel_request", 51 | "entry": { 52 | "channel_id": 0, 53 | "type": "env", 54 | "want_reply": false, 55 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 56 | "accepted": false 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "channel_request", 62 | "entry": { 63 | "channel_id": 0, 64 | "type": "exec", 65 | "want_reply": true, 66 | "payload": "AAAAE2NhdCAvZG9lcy9ub3QvZXhpc3Q", 67 | "accepted": true 68 | } 69 | }, 70 | { 71 | "source": "server", 72 | "type": "channel_request", 73 | "entry": { 74 | "channel_id": 0, 75 | "type": "exit-status", 76 | "want_reply": false, 77 | "payload": "AAAAAQ", 78 | "accepted": false 79 | } 80 | }, 81 | { 82 | "source": "server", 83 | "type": "channel_request", 84 | "entry": { 85 | "channel_id": 0, 86 | "type": "eow@openssh.com", 87 | "want_reply": false, 88 | "payload": "", 89 | "accepted": false 90 | } 91 | }, 92 | { 93 | "source": "server", 94 | "type": "channel_data", 95 | "entry": { 96 | "channel_id": 0, 97 | "data": "cat: /does/not/exist: No such file or directory\r\n" 98 | } 99 | }, 100 | { 101 | "source": "server", 102 | "type": "channel_eof", 103 | "entry": { 104 | "channel_id": 0 105 | } 106 | }, 107 | { 108 | "source": "server", 109 | "type": "channel_close", 110 | "entry": { 111 | "channel_id": 0 112 | } 113 | }, 114 | { 115 | "source": "client", 116 | "type": "connection_close", 117 | "entry": {} 118 | } 119 | ], 120 | "plain_logs": [ 121 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 122 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 123 | "[SOURCE] [channel 0] session requested", 124 | "[SOURCE] rejection of further session channels requested", 125 | "[SOURCE] [channel 0] PTY using terminal \"xterm-256color\" (size 158x48) requested", 126 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 127 | "[SOURCE] [channel 0] command \"cat /does/not/exist\" requested", 128 | "[SOURCE] [channel 0] closed", 129 | "[SOURCE] connection closed" 130 | ], 131 | "json_logs": [ 132 | { 133 | "source": "SOURCE", 134 | "event_type": "no_auth", 135 | "event": { 136 | "user": "jaksi", 137 | "accepted": true 138 | } 139 | }, 140 | { 141 | "source": "SOURCE", 142 | "event_type": "connection", 143 | "event": { 144 | "client_version": "SSH-2.0-Go" 145 | } 146 | }, 147 | { 148 | "source": "SOURCE", 149 | "event_type": "session", 150 | "event": { 151 | "channel_id": 0 152 | } 153 | }, 154 | { 155 | "source": "SOURCE", 156 | "event_type": "no_more_sessions", 157 | "event": {} 158 | }, 159 | { 160 | "source": "SOURCE", 161 | "event_type": "pty", 162 | "event": { 163 | "channel_id": 0, 164 | "terminal": "xterm-256color", 165 | "width": 158, 166 | "height": 48 167 | } 168 | }, 169 | { 170 | "source": "SOURCE", 171 | "event_type": "env", 172 | "event": { 173 | "channel_id": 0, 174 | "name": "LANG", 175 | "value": "en_IE.UTF-8" 176 | } 177 | }, 178 | { 179 | "source": "SOURCE", 180 | "event_type": "exec", 181 | "event": { 182 | "channel_id": 0, 183 | "command": "cat /does/not/exist" 184 | } 185 | }, 186 | { 187 | "source": "SOURCE", 188 | "event_type": "session_close", 189 | "event": { 190 | "channel_id": 0 191 | } 192 | }, 193 | { 194 | "source": "SOURCE", 195 | "event_type": "connection_close", 196 | "event": {} 197 | } 198 | ] 199 | } -------------------------------------------------------------------------------- /replay_tests/raw_exec.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "server", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "hostkeys-00@openssh.com", 20 | "want_reply": false, 21 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "client", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "no-more-sessions@openssh.com", 31 | "want_reply": false, 32 | "payload": "", 33 | "accepted": false, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_request", 40 | "entry": { 41 | "channel_id": 0, 42 | "type": "env", 43 | "want_reply": false, 44 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 45 | "accepted": false 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "channel_request", 51 | "entry": { 52 | "channel_id": 0, 53 | "type": "exec", 54 | "want_reply": true, 55 | "payload": "AAAAE2NhdCAvZG9lcy9ub3QvZXhpc3Q", 56 | "accepted": true 57 | } 58 | }, 59 | { 60 | "source": "server", 61 | "type": "channel_error", 62 | "entry": { 63 | "channel_id": 0, 64 | "data": "cat: /does/not/exist: No such file or directory\n" 65 | } 66 | }, 67 | { 68 | "source": "server", 69 | "type": "channel_request", 70 | "entry": { 71 | "channel_id": 0, 72 | "type": "exit-status", 73 | "want_reply": false, 74 | "payload": "AAAAAQ", 75 | "accepted": false 76 | } 77 | }, 78 | { 79 | "source": "server", 80 | "type": "channel_request", 81 | "entry": { 82 | "channel_id": 0, 83 | "type": "eow@openssh.com", 84 | "want_reply": false, 85 | "payload": "", 86 | "accepted": false 87 | } 88 | }, 89 | { 90 | "source": "server", 91 | "type": "channel_eof", 92 | "entry": { 93 | "channel_id": 0 94 | } 95 | }, 96 | { 97 | "source": "server", 98 | "type": "channel_close", 99 | "entry": { 100 | "channel_id": 0 101 | } 102 | }, 103 | { 104 | "source": "client", 105 | "type": "connection_close", 106 | "entry": {} 107 | } 108 | ], 109 | "plain_logs": [ 110 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 111 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 112 | "[SOURCE] [channel 0] session requested", 113 | "[SOURCE] rejection of further session channels requested", 114 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 115 | "[SOURCE] [channel 0] command \"cat /does/not/exist\" requested", 116 | "[SOURCE] [channel 0] closed", 117 | "[SOURCE] connection closed" 118 | ], 119 | "json_logs": [ 120 | { 121 | "source": "SOURCE", 122 | "event_type": "no_auth", 123 | "event": { 124 | "user": "jaksi", 125 | "accepted": true 126 | } 127 | }, 128 | { 129 | "source": "SOURCE", 130 | "event_type": "connection", 131 | "event": { 132 | "client_version": "SSH-2.0-Go" 133 | } 134 | }, 135 | { 136 | "source": "SOURCE", 137 | "event_type": "session", 138 | "event": { 139 | "channel_id": 0 140 | } 141 | }, 142 | { 143 | "source": "SOURCE", 144 | "event_type": "no_more_sessions", 145 | "event": {} 146 | }, 147 | { 148 | "source": "SOURCE", 149 | "event_type": "env", 150 | "event": { 151 | "channel_id": 0, 152 | "name": "LANG", 153 | "value": "en_IE.UTF-8" 154 | } 155 | }, 156 | { 157 | "source": "SOURCE", 158 | "event_type": "exec", 159 | "event": { 160 | "channel_id": 0, 161 | "command": "cat /does/not/exist" 162 | } 163 | }, 164 | { 165 | "source": "SOURCE", 166 | "event_type": "session_close", 167 | "event": { 168 | "channel_id": 0 169 | } 170 | }, 171 | { 172 | "source": "SOURCE", 173 | "event_type": "connection_close", 174 | "event": {} 175 | } 176 | ] 177 | } -------------------------------------------------------------------------------- /replay_tests/raw_shell.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "client", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "no-more-sessions@openssh.com", 20 | "want_reply": false, 21 | "payload": "", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "server", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "hostkeys-00@openssh.com", 31 | "want_reply": false, 32 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 33 | "accepted": false, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_request", 40 | "entry": { 41 | "channel_id": 0, 42 | "type": "env", 43 | "want_reply": false, 44 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 45 | "accepted": false 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "channel_request", 51 | "entry": { 52 | "channel_id": 0, 53 | "type": "shell", 54 | "want_reply": true, 55 | "payload": "", 56 | "accepted": true 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "channel_data", 62 | "entry": { 63 | "channel_id": 0, 64 | "data": "true\n" 65 | } 66 | }, 67 | { 68 | "source": "client", 69 | "type": "channel_data", 70 | "entry": { 71 | "channel_id": 0, 72 | "data": "false\n" 73 | } 74 | }, 75 | { 76 | "source": "client", 77 | "type": "channel_data", 78 | "entry": { 79 | "channel_id": 0, 80 | "data": "cat /does/not/exist\n" 81 | } 82 | }, 83 | { 84 | "source": "server", 85 | "type": "channel_error", 86 | "entry": { 87 | "channel_id": 0, 88 | "data": "cat: /does/not/exist: No such file or directory\n" 89 | } 90 | }, 91 | { 92 | "source": "client", 93 | "type": "channel_data", 94 | "entry": { 95 | "channel_id": 0, 96 | "data": "echo some test\n" 97 | } 98 | }, 99 | { 100 | "source": "server", 101 | "type": "channel_data", 102 | "entry": { 103 | "channel_id": 0, 104 | "data": "some test\n" 105 | } 106 | }, 107 | { 108 | "source": "client", 109 | "type": "channel_data", 110 | "entry": { 111 | "channel_id": 0, 112 | "data": "something\n" 113 | } 114 | }, 115 | { 116 | "source": "server", 117 | "type": "channel_error", 118 | "entry": { 119 | "channel_id": 0, 120 | "data": "something: command not found\n" 121 | } 122 | }, 123 | { 124 | "source": "client", 125 | "type": "channel_eof", 126 | "entry": { 127 | "channel_id": 0 128 | } 129 | }, 130 | { 131 | "source": "server", 132 | "type": "channel_request", 133 | "entry": { 134 | "channel_id": 0, 135 | "type": "exit-status", 136 | "want_reply": false, 137 | "payload": "AAAAfw", 138 | "accepted": false 139 | } 140 | }, 141 | { 142 | "source": "server", 143 | "type": "channel_close", 144 | "entry": { 145 | "channel_id": 0 146 | } 147 | }, 148 | { 149 | "source": "client", 150 | "type": "connection_close", 151 | "entry": {} 152 | } 153 | ], 154 | "plain_logs": [ 155 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 156 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 157 | "[SOURCE] [channel 0] session requested", 158 | "[SOURCE] rejection of further session channels requested", 159 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 160 | "[SOURCE] [channel 0] shell requested", 161 | "[SOURCE] [channel 0] input: \"true\"", 162 | "[SOURCE] [channel 0] input: \"false\"", 163 | "[SOURCE] [channel 0] input: \"cat /does/not/exist\"", 164 | "[SOURCE] [channel 0] input: \"echo some test\"", 165 | "[SOURCE] [channel 0] input: \"something\"", 166 | "[SOURCE] [channel 0] closed", 167 | "[SOURCE] connection closed" 168 | ], 169 | "json_logs": [ 170 | { 171 | "source": "SOURCE", 172 | "event_type": "no_auth", 173 | "event": { 174 | "user": "jaksi", 175 | "accepted": true 176 | } 177 | }, 178 | { 179 | "source": "SOURCE", 180 | "event_type": "connection", 181 | "event": { 182 | "client_version": "SSH-2.0-Go" 183 | } 184 | }, 185 | { 186 | "source": "SOURCE", 187 | "event_type": "session", 188 | "event": { 189 | "channel_id": 0 190 | } 191 | }, 192 | { 193 | "source": "SOURCE", 194 | "event_type": "no_more_sessions", 195 | "event": {} 196 | }, 197 | { 198 | "source": "SOURCE", 199 | "event_type": "env", 200 | "event": { 201 | "channel_id": 0, 202 | "name": "LANG", 203 | "value": "en_IE.UTF-8" 204 | } 205 | }, 206 | { 207 | "source": "SOURCE", 208 | "event_type": "shell", 209 | "event": { 210 | "channel_id": 0 211 | } 212 | }, 213 | { 214 | "source": "SOURCE", 215 | "event_type": "session_input", 216 | "event": { 217 | "channel_id": 0, 218 | "input": "true" 219 | } 220 | }, 221 | { 222 | "source": "SOURCE", 223 | "event_type": "session_input", 224 | "event": { 225 | "channel_id": 0, 226 | "input": "false" 227 | } 228 | }, 229 | { 230 | "source": "SOURCE", 231 | "event_type": "session_input", 232 | "event": { 233 | "channel_id": 0, 234 | "input": "cat /does/not/exist" 235 | } 236 | }, 237 | { 238 | "source": "SOURCE", 239 | "event_type": "session_input", 240 | "event": { 241 | "channel_id": 0, 242 | "input": "echo some test" 243 | } 244 | }, 245 | { 246 | "source": "SOURCE", 247 | "event_type": "session_input", 248 | "event": { 249 | "channel_id": 0, 250 | "input": "something" 251 | } 252 | }, 253 | { 254 | "source": "SOURCE", 255 | "event_type": "session_close", 256 | "event": { 257 | "channel_id": 0 258 | } 259 | }, 260 | { 261 | "source": "SOURCE", 262 | "event_type": "connection_close", 263 | "event": {} 264 | } 265 | ] 266 | } -------------------------------------------------------------------------------- /replay_tests/raw_shell_exit.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "client", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "no-more-sessions@openssh.com", 20 | "want_reply": false, 21 | "payload": "", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "server", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "hostkeys-00@openssh.com", 31 | "want_reply": false, 32 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 33 | "accepted": false, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_request", 40 | "entry": { 41 | "channel_id": 0, 42 | "type": "env", 43 | "want_reply": false, 44 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 45 | "accepted": false 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "channel_request", 51 | "entry": { 52 | "channel_id": 0, 53 | "type": "shell", 54 | "want_reply": true, 55 | "payload": "", 56 | "accepted": true 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "channel_data", 62 | "entry": { 63 | "channel_id": 0, 64 | "data": "true\n" 65 | } 66 | }, 67 | { 68 | "source": "client", 69 | "type": "channel_data", 70 | "entry": { 71 | "channel_id": 0, 72 | "data": "false\n" 73 | } 74 | }, 75 | { 76 | "source": "client", 77 | "type": "channel_data", 78 | "entry": { 79 | "channel_id": 0, 80 | "data": "cat /does/not/exist\n" 81 | } 82 | }, 83 | { 84 | "source": "server", 85 | "type": "channel_error", 86 | "entry": { 87 | "channel_id": 0, 88 | "data": "cat: " 89 | } 90 | }, 91 | { 92 | "source": "server", 93 | "type": "channel_error", 94 | "entry": { 95 | "channel_id": 0, 96 | "data": "/does/not/exist: No such file or directory\n" 97 | } 98 | }, 99 | { 100 | "source": "client", 101 | "type": "channel_data", 102 | "entry": { 103 | "channel_id": 0, 104 | "data": "echo some test\n" 105 | } 106 | }, 107 | { 108 | "source": "server", 109 | "type": "channel_data", 110 | "entry": { 111 | "channel_id": 0, 112 | "data": "some test\n" 113 | } 114 | }, 115 | { 116 | "source": "client", 117 | "type": "channel_data", 118 | "entry": { 119 | "channel_id": 0, 120 | "data": "something\n" 121 | } 122 | }, 123 | { 124 | "source": "server", 125 | "type": "channel_error", 126 | "entry": { 127 | "channel_id": 0, 128 | "data": "something: command not found\n" 129 | } 130 | }, 131 | { 132 | "source": "client", 133 | "type": "channel_data", 134 | "entry": { 135 | "channel_id": 0, 136 | "data": "exit\n" 137 | } 138 | }, 139 | { 140 | "source": "server", 141 | "type": "channel_request", 142 | "entry": { 143 | "channel_id": 0, 144 | "type": "exit-status", 145 | "want_reply": false, 146 | "payload": "AAAAfw", 147 | "accepted": false 148 | } 149 | }, 150 | { 151 | "source": "server", 152 | "type": "channel_request", 153 | "entry": { 154 | "channel_id": 0, 155 | "type": "eow@openssh.com", 156 | "want_reply": false, 157 | "payload": "", 158 | "accepted": false 159 | } 160 | }, 161 | { 162 | "source": "server", 163 | "type": "channel_eof", 164 | "entry": { 165 | "channel_id": 0 166 | } 167 | }, 168 | { 169 | "source": "server", 170 | "type": "channel_close", 171 | "entry": { 172 | "channel_id": 0 173 | } 174 | }, 175 | { 176 | "source": "client", 177 | "type": "connection_close", 178 | "entry": {} 179 | } 180 | ], 181 | "plain_logs": [ 182 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 183 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 184 | "[SOURCE] [channel 0] session requested", 185 | "[SOURCE] rejection of further session channels requested", 186 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 187 | "[SOURCE] [channel 0] shell requested", 188 | "[SOURCE] [channel 0] input: \"true\"", 189 | "[SOURCE] [channel 0] input: \"false\"", 190 | "[SOURCE] [channel 0] input: \"cat /does/not/exist\"", 191 | "[SOURCE] [channel 0] input: \"echo some test\"", 192 | "[SOURCE] [channel 0] input: \"something\"", 193 | "[SOURCE] [channel 0] input: \"exit\"", 194 | "[SOURCE] [channel 0] closed", 195 | "[SOURCE] connection closed" 196 | ], 197 | "json_logs": [ 198 | { 199 | "source": "SOURCE", 200 | "event_type": "no_auth", 201 | "event": { 202 | "user": "jaksi", 203 | "accepted": true 204 | } 205 | }, 206 | { 207 | "source": "SOURCE", 208 | "event_type": "connection", 209 | "event": { 210 | "client_version": "SSH-2.0-Go" 211 | } 212 | }, 213 | { 214 | "source": "SOURCE", 215 | "event_type": "session", 216 | "event": { 217 | "channel_id": 0 218 | } 219 | }, 220 | { 221 | "source": "SOURCE", 222 | "event_type": "no_more_sessions", 223 | "event": {} 224 | }, 225 | { 226 | "source": "SOURCE", 227 | "event_type": "env", 228 | "event": { 229 | "channel_id": 0, 230 | "name": "LANG", 231 | "value": "en_IE.UTF-8" 232 | } 233 | }, 234 | { 235 | "source": "SOURCE", 236 | "event_type": "shell", 237 | "event": { 238 | "channel_id": 0 239 | } 240 | }, 241 | { 242 | "source": "SOURCE", 243 | "event_type": "session_input", 244 | "event": { 245 | "channel_id": 0, 246 | "input": "true" 247 | } 248 | }, 249 | { 250 | "source": "SOURCE", 251 | "event_type": "session_input", 252 | "event": { 253 | "channel_id": 0, 254 | "input": "false" 255 | } 256 | }, 257 | { 258 | "source": "SOURCE", 259 | "event_type": "session_input", 260 | "event": { 261 | "channel_id": 0, 262 | "input": "cat /does/not/exist" 263 | } 264 | }, 265 | { 266 | "source": "SOURCE", 267 | "event_type": "session_input", 268 | "event": { 269 | "channel_id": 0, 270 | "input": "echo some test" 271 | } 272 | }, 273 | { 274 | "source": "SOURCE", 275 | "event_type": "session_input", 276 | "event": { 277 | "channel_id": 0, 278 | "input": "something" 279 | } 280 | }, 281 | { 282 | "source": "SOURCE", 283 | "event_type": "session_input", 284 | "event": { 285 | "channel_id": 0, 286 | "input": "exit" 287 | } 288 | }, 289 | { 290 | "source": "SOURCE", 291 | "event_type": "session_close", 292 | "event": { 293 | "channel_id": 0 294 | } 295 | }, 296 | { 297 | "source": "SOURCE", 298 | "event_type": "connection_close", 299 | "event": {} 300 | } 301 | ] 302 | } -------------------------------------------------------------------------------- /replay_tests/root.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "root", 3 | "events": [ 4 | { 5 | "source": "client", 6 | "type": "new_channel", 7 | "entry": { 8 | "type": "session", 9 | "extra_data": "", 10 | "accepted": true, 11 | "reject_reason": 0, 12 | "message": "" 13 | } 14 | }, 15 | { 16 | "source": "client", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "no-more-sessions@openssh.com", 20 | "want_reply": false, 21 | "payload": "", 22 | "accepted": false, 23 | "response": "" 24 | } 25 | }, 26 | { 27 | "source": "server", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "hostkeys-00@openssh.com", 31 | "want_reply": false, 32 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 33 | "accepted": false, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "channel_request", 40 | "entry": { 41 | "channel_id": 0, 42 | "type": "pty-req", 43 | "want_reply": true, 44 | "payload": "AAAADnh0ZXJtLTI1NmNvbG9yAAAAngAAADAAAAWWAAADYgAAAQCBAAAlgIAAACWAAQAAAAMCAAAAHAMAAAB/BAAAABUFAAAABAYAAAD/BwAAAP8IAAAAEQkAAAATCgAAABoLAAAAGQwAAAASDQAAABcOAAAAFhEAAAAUEgAAAA8eAAAAAB8AAAAAIAAAAAAhAAAAACIAAAAAIwAAAAAkAAAAASYAAAAAJwAAAAEoAAAAACkAAAABKgAAAAEyAAAAATMAAAABNQAAAAE2AAAAATcAAAAAOAAAAAA5AAAAADoAAAAAOwAAAAE8AAAAAT0AAAABPgAAAAFGAAAAAUgAAAABSQAAAABKAAAAAEsAAAAAWgAAAAFbAAAAAVwAAAAAXQAAAAAA", 45 | "accepted": true 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "channel_request", 51 | "entry": { 52 | "channel_id": 0, 53 | "type": "env", 54 | "want_reply": false, 55 | "payload": "AAAABExBTkcAAAALZW5fSUUuVVRGLTg", 56 | "accepted": false 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "channel_request", 62 | "entry": { 63 | "channel_id": 0, 64 | "type": "shell", 65 | "want_reply": true, 66 | "payload": "", 67 | "accepted": true 68 | } 69 | }, 70 | { 71 | "source": "server", 72 | "type": "channel_data", 73 | "entry": { 74 | "channel_id": 0, 75 | "data": "# " 76 | } 77 | }, 78 | { 79 | "source": "client", 80 | "type": "channel_data", 81 | "entry": { 82 | "channel_id": 0, 83 | "data": "s" 84 | } 85 | }, 86 | { 87 | "source": "server", 88 | "type": "channel_data", 89 | "entry": { 90 | "channel_id": 0, 91 | "data": "s" 92 | } 93 | }, 94 | { 95 | "source": "client", 96 | "type": "channel_data", 97 | "entry": { 98 | "channel_id": 0, 99 | "data": "u" 100 | } 101 | }, 102 | { 103 | "source": "server", 104 | "type": "channel_data", 105 | "entry": { 106 | "channel_id": 0, 107 | "data": "u" 108 | } 109 | }, 110 | { 111 | "source": "client", 112 | "type": "channel_data", 113 | "entry": { 114 | "channel_id": 0, 115 | "data": " " 116 | } 117 | }, 118 | { 119 | "source": "server", 120 | "type": "channel_data", 121 | "entry": { 122 | "channel_id": 0, 123 | "data": " " 124 | } 125 | }, 126 | { 127 | "source": "client", 128 | "type": "channel_data", 129 | "entry": { 130 | "channel_id": 0, 131 | "data": "j" 132 | } 133 | }, 134 | { 135 | "source": "server", 136 | "type": "channel_data", 137 | "entry": { 138 | "channel_id": 0, 139 | "data": "j" 140 | } 141 | }, 142 | { 143 | "source": "client", 144 | "type": "channel_data", 145 | "entry": { 146 | "channel_id": 0, 147 | "data": "a" 148 | } 149 | }, 150 | { 151 | "source": "server", 152 | "type": "channel_data", 153 | "entry": { 154 | "channel_id": 0, 155 | "data": "a" 156 | } 157 | }, 158 | { 159 | "source": "client", 160 | "type": "channel_data", 161 | "entry": { 162 | "channel_id": 0, 163 | "data": "k" 164 | } 165 | }, 166 | { 167 | "source": "server", 168 | "type": "channel_data", 169 | "entry": { 170 | "channel_id": 0, 171 | "data": "k" 172 | } 173 | }, 174 | { 175 | "source": "client", 176 | "type": "channel_data", 177 | "entry": { 178 | "channel_id": 0, 179 | "data": "s" 180 | } 181 | }, 182 | { 183 | "source": "server", 184 | "type": "channel_data", 185 | "entry": { 186 | "channel_id": 0, 187 | "data": "s" 188 | } 189 | }, 190 | { 191 | "source": "client", 192 | "type": "channel_data", 193 | "entry": { 194 | "channel_id": 0, 195 | "data": "i" 196 | } 197 | }, 198 | { 199 | "source": "server", 200 | "type": "channel_data", 201 | "entry": { 202 | "channel_id": 0, 203 | "data": "i" 204 | } 205 | }, 206 | { 207 | "source": "client", 208 | "type": "channel_data", 209 | "entry": { 210 | "channel_id": 0, 211 | "data": "\r" 212 | } 213 | }, 214 | { 215 | "source": "server", 216 | "type": "channel_data", 217 | "entry": { 218 | "channel_id": 0, 219 | "data": "\r\n" 220 | } 221 | }, 222 | { 223 | "source": "server", 224 | "type": "channel_data", 225 | "entry": { 226 | "channel_id": 0, 227 | "data": "$ " 228 | } 229 | }, 230 | { 231 | "source": "client", 232 | "type": "channel_data", 233 | "entry": { 234 | "channel_id": 0, 235 | "data": "e" 236 | } 237 | }, 238 | { 239 | "source": "server", 240 | "type": "channel_data", 241 | "entry": { 242 | "channel_id": 0, 243 | "data": "e" 244 | } 245 | }, 246 | { 247 | "source": "client", 248 | "type": "channel_data", 249 | "entry": { 250 | "channel_id": 0, 251 | "data": "x" 252 | } 253 | }, 254 | { 255 | "source": "server", 256 | "type": "channel_data", 257 | "entry": { 258 | "channel_id": 0, 259 | "data": "x" 260 | } 261 | }, 262 | { 263 | "source": "client", 264 | "type": "channel_data", 265 | "entry": { 266 | "channel_id": 0, 267 | "data": "i" 268 | } 269 | }, 270 | { 271 | "source": "server", 272 | "type": "channel_data", 273 | "entry": { 274 | "channel_id": 0, 275 | "data": "i" 276 | } 277 | }, 278 | { 279 | "source": "client", 280 | "type": "channel_data", 281 | "entry": { 282 | "channel_id": 0, 283 | "data": "t" 284 | } 285 | }, 286 | { 287 | "source": "server", 288 | "type": "channel_data", 289 | "entry": { 290 | "channel_id": 0, 291 | "data": "t" 292 | } 293 | }, 294 | { 295 | "source": "client", 296 | "type": "channel_data", 297 | "entry": { 298 | "channel_id": 0, 299 | "data": "\r" 300 | } 301 | }, 302 | { 303 | "source": "server", 304 | "type": "channel_data", 305 | "entry": { 306 | "channel_id": 0, 307 | "data": "\r\n# " 308 | } 309 | }, 310 | { 311 | "source": "client", 312 | "type": "channel_data", 313 | "entry": { 314 | "channel_id": 0, 315 | "data": "e" 316 | } 317 | }, 318 | { 319 | "source": "server", 320 | "type": "channel_data", 321 | "entry": { 322 | "channel_id": 0, 323 | "data": "e" 324 | } 325 | }, 326 | { 327 | "source": "client", 328 | "type": "channel_data", 329 | "entry": { 330 | "channel_id": 0, 331 | "data": "x" 332 | } 333 | }, 334 | { 335 | "source": "server", 336 | "type": "channel_data", 337 | "entry": { 338 | "channel_id": 0, 339 | "data": "x" 340 | } 341 | }, 342 | { 343 | "source": "client", 344 | "type": "channel_data", 345 | "entry": { 346 | "channel_id": 0, 347 | "data": "i" 348 | } 349 | }, 350 | { 351 | "source": "server", 352 | "type": "channel_data", 353 | "entry": { 354 | "channel_id": 0, 355 | "data": "i" 356 | } 357 | }, 358 | { 359 | "source": "client", 360 | "type": "channel_data", 361 | "entry": { 362 | "channel_id": 0, 363 | "data": "t" 364 | } 365 | }, 366 | { 367 | "source": "server", 368 | "type": "channel_data", 369 | "entry": { 370 | "channel_id": 0, 371 | "data": "t" 372 | } 373 | }, 374 | { 375 | "source": "client", 376 | "type": "channel_data", 377 | "entry": { 378 | "channel_id": 0, 379 | "data": "\r" 380 | } 381 | }, 382 | { 383 | "source": "server", 384 | "type": "channel_request", 385 | "entry": { 386 | "channel_id": 0, 387 | "type": "exit-status", 388 | "want_reply": false, 389 | "payload": "AAAAAA", 390 | "accepted": false 391 | } 392 | }, 393 | { 394 | "source": "server", 395 | "type": "channel_data", 396 | "entry": { 397 | "channel_id": 0, 398 | "data": "\r\n" 399 | } 400 | }, 401 | { 402 | "source": "server", 403 | "type": "channel_request", 404 | "entry": { 405 | "channel_id": 0, 406 | "type": "eow@openssh.com", 407 | "want_reply": false, 408 | "payload": "", 409 | "accepted": false 410 | } 411 | }, 412 | { 413 | "source": "server", 414 | "type": "channel_eof", 415 | "entry": { 416 | "channel_id": 0 417 | } 418 | }, 419 | { 420 | "source": "server", 421 | "type": "channel_close", 422 | "entry": { 423 | "channel_id": 0 424 | } 425 | }, 426 | { 427 | "source": "client", 428 | "type": "connection_close", 429 | "entry": {} 430 | } 431 | ], 432 | "plain_logs": [ 433 | "[SOURCE] authentication for user \"root\" without credentials accepted", 434 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 435 | "[SOURCE] [channel 0] session requested", 436 | "[SOURCE] rejection of further session channels requested", 437 | "[SOURCE] [channel 0] PTY using terminal \"xterm-256color\" (size 158x48) requested", 438 | "[SOURCE] [channel 0] environment variable \"LANG\" with value \"en_IE.UTF-8\" requested", 439 | "[SOURCE] [channel 0] shell requested", 440 | "[SOURCE] [channel 0] input: \"su jaksi\"", 441 | "[SOURCE] [channel 0] input: \"exit\"", 442 | "[SOURCE] [channel 0] input: \"exit\"", 443 | "[SOURCE] [channel 0] closed", 444 | "[SOURCE] connection closed" 445 | ], 446 | "json_logs": [ 447 | { 448 | "source": "SOURCE", 449 | "event_type": "no_auth", 450 | "event": { 451 | "user": "root", 452 | "accepted": true 453 | } 454 | }, 455 | { 456 | "source": "SOURCE", 457 | "event_type": "connection", 458 | "event": { 459 | "client_version": "SSH-2.0-Go" 460 | } 461 | }, 462 | { 463 | "source": "SOURCE", 464 | "event_type": "session", 465 | "event": { 466 | "channel_id": 0 467 | } 468 | }, 469 | { 470 | "source": "SOURCE", 471 | "event_type": "no_more_sessions", 472 | "event": {} 473 | }, 474 | { 475 | "source": "SOURCE", 476 | "event_type": "pty", 477 | "event": { 478 | "channel_id": 0, 479 | "terminal": "xterm-256color", 480 | "width": 158, 481 | "height": 48 482 | } 483 | }, 484 | { 485 | "source": "SOURCE", 486 | "event_type": "env", 487 | "event": { 488 | "channel_id": 0, 489 | "name": "LANG", 490 | "value": "en_IE.UTF-8" 491 | } 492 | }, 493 | { 494 | "source": "SOURCE", 495 | "event_type": "shell", 496 | "event": { 497 | "channel_id": 0 498 | } 499 | }, 500 | { 501 | "source": "SOURCE", 502 | "event_type": "session_input", 503 | "event": { 504 | "channel_id": 0, 505 | "input": "su jaksi" 506 | } 507 | }, 508 | { 509 | "source": "SOURCE", 510 | "event_type": "session_input", 511 | "event": { 512 | "channel_id": 0, 513 | "input": "exit" 514 | } 515 | }, 516 | { 517 | "source": "SOURCE", 518 | "event_type": "session_input", 519 | "event": { 520 | "channel_id": 0, 521 | "input": "exit" 522 | } 523 | }, 524 | { 525 | "source": "SOURCE", 526 | "event_type": "session_close", 527 | "event": { 528 | "channel_id": 0 529 | } 530 | }, 531 | { 532 | "source": "SOURCE", 533 | "event_type": "connection_close", 534 | "event": {} 535 | } 536 | ] 537 | } -------------------------------------------------------------------------------- /replay_tests/tcpip-forward.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "jaksi", 3 | "events": [ 4 | { 5 | "source": "server", 6 | "type": "global_request", 7 | "entry": { 8 | "type": "hostkeys-00@openssh.com", 9 | "want_reply": false, 10 | "payload": "AAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA4KuAMkkzdgouYgtZWZPkyCknD4D8xy23tRW2udglFGXd8bmF7Mz7co68+ewdd3OdQP/oSIO46MLL+ke5xQ1nZy5wNuzxJD0dMbzCCGHgyo5wg16l2lVFaRV8rW/ulvIS0nX2RXnyhYGHRBCOOFErkL5yk8P0iuGALi+5p8GRCGyclGclabFu2Z02v4d3pgYMoR+I4+gnEK/WnEk47UvYMiSYqXc8rBU0Xv4hWRxE3WGwZcx+m3GPf6tOYx03fcrg+p8xcdJO345KmJ19NEhPl09JH8Obggwl/OlL8mZkz86oT9YMivRjk24eII5aa7QbvDmCOM+z3wsV3disTNcZ1zlEjtMw/wwQ866/cMuyK+EUY3+9tuapp0M+EStgZFORWbx7pZT/iP9zfFN2t85xN6TNRF/gYCRQDx5UX3oxFlCaGm82qnm2BI7veiMCLzVnk47Cq0vF3zEQuzP1vGBQpHc/3XXWNraq/W7JYR2IJTr8ZAaKbww7jWZ8vv32762DAAAAaAAAABNlY2RzYS1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSUJIs7r7i/6XZ/vbGPCq9kNwvR48uvv6fy9HJ4yQaEx4Uo4bTSyNgK1J0uR23zbQUKtsi+7iZcAcu1WZm1HwTwAAAAMwAAAAtzc2gtZWQyNTUxOQAAACBbaYmWWd6DK13xllCBp68I7hmt8/s5s3gOyMUabBvIiw", 11 | "accepted": false, 12 | "response": "" 13 | } 14 | }, 15 | { 16 | "source": "client", 17 | "type": "global_request", 18 | "entry": { 19 | "type": "tcpip-forward", 20 | "want_reply": true, 21 | "payload": "AAAACWxvY2FsaG9zdAAAAAA", 22 | "accepted": true, 23 | "response": "AACPkQ" 24 | } 25 | }, 26 | { 27 | "source": "client", 28 | "type": "global_request", 29 | "entry": { 30 | "type": "tcpip-forward", 31 | "want_reply": true, 32 | "payload": "AAAACWxvY2FsaG9zdAAACSk", 33 | "accepted": true, 34 | "response": "" 35 | } 36 | }, 37 | { 38 | "source": "client", 39 | "type": "global_request", 40 | "entry": { 41 | "type": "no-more-sessions@openssh.com", 42 | "want_reply": false, 43 | "payload": "", 44 | "accepted": false, 45 | "response": "" 46 | } 47 | }, 48 | { 49 | "source": "client", 50 | "type": "global_request", 51 | "entry": { 52 | "type": "cancel-tcpip-forward", 53 | "want_reply": false, 54 | "payload": "AAAACWxvY2FsaG9zdAAACSk", 55 | "accepted": false, 56 | "response": "" 57 | } 58 | }, 59 | { 60 | "source": "client", 61 | "type": "connection_close", 62 | "entry": {} 63 | } 64 | ], 65 | "plain_logs": [ 66 | "[SOURCE] authentication for user \"jaksi\" without credentials accepted", 67 | "[SOURCE] connection with client version \"SSH-2.0-Go\" established", 68 | "[SOURCE] TCP/IP forwarding on localhost:0 requested", 69 | "[SOURCE] TCP/IP forwarding on localhost:2345 requested", 70 | "[SOURCE] rejection of further session channels requested", 71 | "[SOURCE] TCP/IP forwarding on localhost:2345 canceled", 72 | "[SOURCE] connection closed" 73 | ], 74 | "json_logs": [ 75 | { 76 | "source": "SOURCE", 77 | "event_type": "no_auth", 78 | "event": { 79 | "user": "jaksi", 80 | "accepted": true 81 | } 82 | }, 83 | { 84 | "source": "SOURCE", 85 | "event_type": "connection", 86 | "event": { 87 | "client_version": "SSH-2.0-Go" 88 | } 89 | }, 90 | { 91 | "source": "SOURCE", 92 | "event_type": "tcpip_forward", 93 | "event": { 94 | "address": "localhost:0" 95 | } 96 | }, 97 | { 98 | "source": "SOURCE", 99 | "event_type": "tcpip_forward", 100 | "event": { 101 | "address": "localhost:2345" 102 | } 103 | }, 104 | { 105 | "source": "SOURCE", 106 | "event_type": "no_more_sessions", 107 | "event": {} 108 | }, 109 | { 110 | "source": "SOURCE", 111 | "event_type": "cancel_tcpip_forward", 112 | "event": { 113 | "address": "localhost:2345" 114 | } 115 | }, 116 | { 117 | "source": "SOURCE", 118 | "event_type": "connection_close", 119 | "event": {} 120 | } 121 | ] 122 | } -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | cryptoRand "crypto/rand" 6 | "encoding/binary" 7 | "errors" 8 | mathRand "math/rand" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promauto" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | type globalRequestPayload interface { 16 | reply(context *connContext) []byte 17 | logEntry(context *connContext) logEntry 18 | } 19 | 20 | type globalRequestPayloadParser func(data []byte, context *connContext) (globalRequestPayload, error) 21 | 22 | type tcpipRequest struct { 23 | Address string 24 | Port uint32 25 | } 26 | 27 | func (request tcpipRequest) reply(context *connContext) []byte { 28 | if request.Port != 0 { 29 | return nil 30 | } 31 | return ssh.Marshal(struct{ port uint32 }{uint32(mathRand.Intn(65536-1024) + 1024)}) 32 | } 33 | func (request tcpipRequest) logEntry(context *connContext) logEntry { 34 | return tcpipForwardLog{ 35 | Address: getAddressLog(request.Address, int(request.Port), context.cfg), 36 | } 37 | } 38 | 39 | type cancelTCPIPRequest struct { 40 | Address string 41 | Port uint32 42 | } 43 | 44 | func (request cancelTCPIPRequest) reply(context *connContext) []byte { 45 | return nil 46 | } 47 | func (request cancelTCPIPRequest) logEntry(context *connContext) logEntry { 48 | return cancelTCPIPForwardLog{ 49 | Address: getAddressLog(request.Address, int(request.Port), context.cfg), 50 | } 51 | } 52 | 53 | type noMoreSessionsRequest struct { 54 | } 55 | 56 | func (request noMoreSessionsRequest) reply(context *connContext) []byte { 57 | return nil 58 | } 59 | func (request noMoreSessionsRequest) logEntry(context *connContext) logEntry { 60 | return noMoreSessionsLog{} 61 | } 62 | 63 | type hostKeysProveRequest struct { 64 | hostKeyIndices []int 65 | } 66 | 67 | func (request hostKeysProveRequest) reply(context *connContext) []byte { 68 | signatures := make([][]byte, len(request.hostKeyIndices)) 69 | for i, index := range request.hostKeyIndices { 70 | signature, err := context.cfg.parsedHostKeys[index].Sign(cryptoRand.Reader, ssh.Marshal(struct { 71 | requestType, sessionID, hostKey string 72 | }{ 73 | "hostkeys-prove-00@openssh.com", 74 | string(context.SessionID()), 75 | string(context.cfg.parsedHostKeys[index].PublicKey().Marshal()), 76 | })) 77 | if err != nil { 78 | warningLogger.Printf("Failed to sign host key: %v", err) 79 | return nil 80 | } 81 | signatures[i] = ssh.Marshal(signature) 82 | } 83 | return marshalBytes(signatures) 84 | } 85 | func (request hostKeysProveRequest) logEntry(context *connContext) logEntry { 86 | hostKeyFiles := make([]string, len(request.hostKeyIndices)) 87 | for i, index := range request.hostKeyIndices { 88 | hostKeyFiles[i] = context.cfg.Server.HostKeys[index] 89 | } 90 | return hostKeysProveLog{ 91 | HostKeyFiles: hostKeyFiles, 92 | } 93 | } 94 | 95 | var globalRequestPayloads = map[string]globalRequestPayloadParser{ 96 | "tcpip-forward": func(data []byte, context *connContext) (globalRequestPayload, error) { 97 | payload := &tcpipRequest{} 98 | if err := ssh.Unmarshal(data, payload); err != nil { 99 | return nil, err 100 | } 101 | return payload, nil 102 | }, 103 | "cancel-tcpip-forward": func(data []byte, context *connContext) (globalRequestPayload, error) { 104 | payload := &cancelTCPIPRequest{} 105 | if err := ssh.Unmarshal(data, payload); err != nil { 106 | return nil, err 107 | } 108 | return payload, nil 109 | }, 110 | "no-more-sessions@openssh.com": func(data []byte, context *connContext) (globalRequestPayload, error) { 111 | if len(data) != 0 { 112 | return nil, errors.New("invalid request payload") 113 | } 114 | return &noMoreSessionsRequest{}, nil 115 | }, 116 | "hostkeys-prove-00@openssh.com": func(data []byte, context *connContext) (globalRequestPayload, error) { 117 | payloadBytes, err := unmarshalBytes(data) 118 | if err != nil { 119 | return nil, err 120 | } 121 | hostKeyIndices := make([]int, len(payloadBytes)) 122 | for i, publicKeyBytes := range payloadBytes { 123 | found := false 124 | for j, hostKey := range context.cfg.parsedHostKeys { 125 | if bytes.Equal(publicKeyBytes, hostKey.PublicKey().Marshal()) { 126 | hostKeyIndices[i] = j 127 | found = true 128 | break 129 | } 130 | } 131 | if !found { 132 | return nil, errors.New("host key not found") 133 | } 134 | } 135 | return &hostKeysProveRequest{hostKeyIndices}, nil 136 | }, 137 | } 138 | 139 | var ( 140 | globalRequestsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ 141 | Name: "sshesame_global_requests_total", 142 | Help: "Total number of global requests", 143 | }, []string{"type"}) 144 | ) 145 | 146 | func handleGlobalRequest(request *ssh.Request, context *connContext) error { 147 | parser := globalRequestPayloads[request.Type] 148 | if parser == nil { 149 | globalRequestsMetric.WithLabelValues("unknown").Inc() 150 | warningLogger.Printf("Unsupported global request type %v", request.Type) 151 | if request.WantReply { 152 | if err := request.Reply(false, nil); err != nil { 153 | return err 154 | } 155 | } 156 | return nil 157 | } 158 | globalRequestsMetric.WithLabelValues(request.Type).Inc() 159 | payload, err := parser(request.Payload, context) 160 | if err != nil { 161 | return err 162 | } 163 | switch payload.(type) { 164 | case *noMoreSessionsRequest: 165 | context.noMoreSessions = true 166 | } 167 | if request.WantReply { 168 | if err := request.Reply(true, payload.reply(context)); err != nil { 169 | return err 170 | } 171 | } 172 | context.logEvent(payload.logEntry(context)) 173 | return nil 174 | } 175 | 176 | func marshalBytes(data [][]byte) []byte { 177 | var result []byte 178 | for _, b := range data { 179 | result = append(result, ssh.Marshal(struct{ string }{string(b)})...) 180 | } 181 | return result 182 | } 183 | 184 | func unmarshalBytes(data []byte) ([][]byte, error) { 185 | var result [][]byte 186 | for len(data) > 0 { 187 | if len(data) < 4 { 188 | return nil, errors.New("invalid request payload") 189 | } 190 | length := binary.BigEndian.Uint32(data[:4]) 191 | if len(data) < 4+int(length) { 192 | return nil, errors.New("invalid request payload") 193 | } 194 | var s struct{ S string } 195 | if err := ssh.Unmarshal(data[:4+length], &s); err != nil { 196 | return nil, err 197 | } 198 | result = append(result, []byte(s.S)) 199 | data = data[4+length:] 200 | } 201 | return result, nil 202 | } 203 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | "strings" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/prometheus/client_golang/prometheus/promauto" 11 | "golang.org/x/crypto/ssh" 12 | "golang.org/x/term" 13 | ) 14 | 15 | type ptyRequestPayload struct { 16 | Term string 17 | Width, Height, PixelWidth, PixelHeight uint32 18 | Modes string 19 | } 20 | 21 | func (request ptyRequestPayload) reply() []byte { 22 | return nil 23 | } 24 | func (request ptyRequestPayload) logEntry(channelID int) logEntry { 25 | return ptyLog{ 26 | channelLog: channelLog{ 27 | ChannelID: channelID, 28 | }, 29 | Terminal: request.Term, 30 | Width: request.Width, 31 | Height: request.Height, 32 | } 33 | } 34 | 35 | type shellRequestPayload struct{} 36 | 37 | func (request shellRequestPayload) reply() []byte { 38 | return nil 39 | } 40 | func (request shellRequestPayload) logEntry(channelID int) logEntry { 41 | return shellLog{ 42 | channelLog: channelLog{ 43 | ChannelID: channelID, 44 | }, 45 | } 46 | } 47 | 48 | type x11RequestPayload struct { 49 | SingleConnection bool 50 | AuthProtocol, AuthCookie string 51 | ScreenNumber uint32 52 | } 53 | 54 | func (request x11RequestPayload) reply() []byte { 55 | return nil 56 | } 57 | func (request x11RequestPayload) logEntry(channelID int) logEntry { 58 | return x11Log{ 59 | channelLog: channelLog{ 60 | ChannelID: channelID, 61 | }, 62 | Screen: request.ScreenNumber, 63 | } 64 | } 65 | 66 | type envRequestPayload struct { 67 | Name, Value string 68 | } 69 | 70 | func (request envRequestPayload) reply() []byte { 71 | return nil 72 | } 73 | func (request envRequestPayload) logEntry(channelID int) logEntry { 74 | return envLog{ 75 | channelLog: channelLog{ 76 | ChannelID: channelID, 77 | }, 78 | Name: request.Name, 79 | Value: request.Value, 80 | } 81 | } 82 | 83 | type execRequestPayload struct { 84 | Command string 85 | } 86 | 87 | func (request execRequestPayload) reply() []byte { 88 | return nil 89 | } 90 | func (request execRequestPayload) logEntry(channelID int) logEntry { 91 | return execLog{ 92 | channelLog: channelLog{ 93 | ChannelID: channelID, 94 | }, 95 | Command: request.Command, 96 | } 97 | } 98 | 99 | type subsystemRequestPayload struct { 100 | Subsystem string 101 | } 102 | 103 | func (request subsystemRequestPayload) reply() []byte { 104 | return nil 105 | } 106 | func (request subsystemRequestPayload) logEntry(channelID int) logEntry { 107 | return subsystemLog{ 108 | channelLog: channelLog{ 109 | ChannelID: channelID, 110 | }, 111 | Subsystem: request.Subsystem, 112 | } 113 | } 114 | 115 | type windowChangeRequestPayload struct { 116 | Width, Height, PixelWidth, PixelHeight uint32 117 | } 118 | 119 | func (request windowChangeRequestPayload) reply() []byte { 120 | return nil 121 | } 122 | func (request windowChangeRequestPayload) logEntry(channelID int) logEntry { 123 | return windowChangeLog{ 124 | channelLog: channelLog{ 125 | ChannelID: channelID, 126 | }, 127 | Width: request.Width, 128 | Height: request.Height, 129 | } 130 | } 131 | 132 | type sessionContext struct { 133 | channelContext 134 | ssh.Channel 135 | inputChan chan string 136 | active bool 137 | pty bool 138 | } 139 | 140 | type scannerReadLiner struct { 141 | scanner *bufio.Scanner 142 | inputChan chan<- string 143 | } 144 | 145 | func (r scannerReadLiner) ReadLine() (string, error) { 146 | if !r.scanner.Scan() { 147 | if err := r.scanner.Err(); err != nil { 148 | return "", err 149 | } 150 | return "", io.EOF 151 | } 152 | line := r.scanner.Text() 153 | r.inputChan <- line 154 | return line, nil 155 | } 156 | 157 | type terminalReadLiner struct { 158 | terminal *term.Terminal 159 | inputChan chan<- string 160 | } 161 | 162 | type clientEOFError struct{} 163 | 164 | var clientEOF = clientEOFError{} 165 | 166 | func (clientEOFError) Error() string { 167 | return "Client EOF" 168 | } 169 | 170 | func (r terminalReadLiner) ReadLine() (string, error) { 171 | line, err := r.terminal.ReadLine() 172 | if err == nil || line != "" { 173 | r.inputChan <- line 174 | } 175 | if err == io.EOF { 176 | return line, clientEOF 177 | } 178 | return line, err 179 | } 180 | 181 | func (context *sessionContext) handleProgram(program []string) { 182 | context.active = true 183 | var stdin readLiner 184 | var stdout, stderr io.Writer 185 | if context.pty { 186 | terminal := term.NewTerminal(context, "") 187 | stdin = terminalReadLiner{terminal, context.inputChan} 188 | stdout = terminal 189 | stderr = terminal 190 | } else { 191 | stdin = scannerReadLiner{bufio.NewScanner(context), context.inputChan} 192 | stdout = context 193 | stderr = context.Stderr() 194 | } 195 | go func() { 196 | defer close(context.inputChan) 197 | 198 | result, err := executeProgram(commandContext{program, stdin, stdout, stderr, context.pty, context.User()}) 199 | if err != nil && err != io.EOF && err != clientEOF { 200 | warningLogger.Printf("Error executing program: %s", err) 201 | return 202 | } 203 | 204 | if err == clientEOF && context.pty { 205 | if _, err := context.Write([]byte("\r\n")); err != nil { 206 | warningLogger.Printf("Error sending CRLF: %s", err) 207 | return 208 | } 209 | } 210 | 211 | if _, err := context.SendRequest("exit-status", false, ssh.Marshal(struct { 212 | ExitStatus uint32 213 | }{result})); err != nil { 214 | warningLogger.Printf("Error sending exit status: %s", err) 215 | return 216 | } 217 | 218 | if (context.pty && err == clientEOF) || err == nil { 219 | if _, err := context.SendRequest("eow@openssh.com", false, nil); err != nil { 220 | warningLogger.Printf("Error sending EOW: %s", err) 221 | return 222 | } 223 | } 224 | 225 | if err := context.CloseWrite(); err != nil { 226 | warningLogger.Printf("Error sending EOF: %s", err) 227 | return 228 | } 229 | 230 | if err := context.Close(); err != nil { 231 | warningLogger.Printf("Error closing channel: %s", err) 232 | return 233 | } 234 | }() 235 | } 236 | 237 | func (context *sessionContext) handleRequest(request *ssh.Request) error { 238 | switch request.Type { 239 | case "pty-req": 240 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 241 | if !context.active { 242 | if context.pty { 243 | return errors.New("a pty is already requested") 244 | } 245 | payload := &ptyRequestPayload{} 246 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 247 | return err 248 | } 249 | context.logEvent(payload.logEntry(context.channelID)) 250 | if err := request.Reply(true, payload.reply()); err != nil { 251 | return err 252 | } 253 | context.pty = true 254 | return nil 255 | } 256 | case "shell": 257 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 258 | if !context.active { 259 | if len(request.Payload) != 0 { 260 | return errors.New("invalid request payload") 261 | } 262 | payload := &shellRequestPayload{} 263 | context.logEvent(payload.logEntry(context.channelID)) 264 | if err := request.Reply(true, payload.reply()); err != nil { 265 | return err 266 | } 267 | context.active = true 268 | context.handleProgram(shellProgram) 269 | return nil 270 | } 271 | case "x11-req": 272 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 273 | if !context.active { 274 | payload := &x11RequestPayload{} 275 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 276 | return err 277 | } 278 | context.logEvent(payload.logEntry(context.channelID)) 279 | return request.Reply(true, payload.reply()) 280 | } 281 | case "env": 282 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 283 | if !context.active { 284 | payload := &envRequestPayload{} 285 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 286 | return err 287 | } 288 | context.logEvent(payload.logEntry(context.channelID)) 289 | return request.Reply(true, payload.reply()) 290 | } 291 | case "exec": 292 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 293 | if !context.active { 294 | payload := &execRequestPayload{} 295 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 296 | return err 297 | } 298 | context.logEvent(payload.logEntry(context.channelID)) 299 | if err := request.Reply(true, payload.reply()); err != nil { 300 | return err 301 | } 302 | context.active = true 303 | context.handleProgram(strings.Fields(payload.Command)) 304 | return nil 305 | } 306 | case "subsystem": 307 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 308 | if !context.active { 309 | payload := &subsystemRequestPayload{} 310 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 311 | return err 312 | } 313 | context.logEvent(payload.logEntry(context.channelID)) 314 | if err := request.Reply(true, payload.reply()); err != nil { 315 | return err 316 | } 317 | context.active = true 318 | context.handleProgram(strings.Fields(payload.Subsystem)) 319 | return nil 320 | } 321 | case "window-change": 322 | sessionChannelRequestsMetric.WithLabelValues(request.Type).Inc() 323 | payload := &windowChangeRequestPayload{} 324 | if err := ssh.Unmarshal(request.Payload, payload); err != nil { 325 | return err 326 | } 327 | context.logEvent(payload.logEntry(context.channelID)) 328 | return request.Reply(true, payload.reply()) 329 | default: 330 | sessionChannelRequestsMetric.WithLabelValues("unknown").Inc() 331 | } 332 | warningLogger.Printf("Rejected session request: %s", request.Type) 333 | return request.Reply(false, nil) 334 | } 335 | 336 | var ( 337 | sessionChannelsMetric = promauto.NewCounter(prometheus.CounterOpts{ 338 | Name: "sshesame_session_channels_total", 339 | Help: "Total number of session channels", 340 | }) 341 | activeSessionChannelsMetric = promauto.NewGauge(prometheus.GaugeOpts{ 342 | Name: "sshesame_active_session_channels", 343 | Help: "Number of active session channels", 344 | }) 345 | sessionChannelRequestsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ 346 | Name: "sshesame_session_channel_requests_total", 347 | Help: "Total number of session channel requests", 348 | }, []string{"type"}) 349 | ) 350 | 351 | func handleSessionChannel(newChannel ssh.NewChannel, context channelContext) error { 352 | if context.noMoreSessions { 353 | return errors.New("no more sessions were supposed to be requested") 354 | } 355 | if len(newChannel.ExtraData()) != 0 { 356 | return errors.New("invalid channel data") 357 | } 358 | sessionChannelsMetric.Inc() 359 | activeSessionChannelsMetric.Inc() 360 | defer activeSessionChannelsMetric.Dec() 361 | channel, requests, err := newChannel.Accept() 362 | if err != nil { 363 | return err 364 | } 365 | context.logEvent(sessionLog{ 366 | channelLog: channelLog{ 367 | ChannelID: context.channelID, 368 | }, 369 | }) 370 | defer context.logEvent(sessionCloseLog{ 371 | channelLog: channelLog{ 372 | ChannelID: context.channelID, 373 | }, 374 | }) 375 | 376 | inputChan := make(chan string) 377 | session := sessionContext{context, channel, inputChan, false, false} 378 | 379 | for inputChan != nil || requests != nil { 380 | select { 381 | case input, ok := <-inputChan: 382 | if !ok { 383 | inputChan = nil 384 | continue 385 | } 386 | context.logEvent(sessionInputLog{ 387 | channelLog: channelLog{ 388 | ChannelID: context.channelID, 389 | }, 390 | Input: input, 391 | }) 392 | case request, ok := <-requests: 393 | if !ok { 394 | requests = nil 395 | if !session.active { 396 | close(inputChan) 397 | } 398 | continue 399 | } 400 | context.logEvent(debugChannelRequestLog{ 401 | channelLog: channelLog{ChannelID: context.channelID}, 402 | RequestType: request.Type, 403 | WantReply: request.WantReply, 404 | Payload: string(request.Payload), 405 | }) 406 | if err := session.handleRequest(request); err != nil { 407 | return err 408 | } 409 | } 410 | } 411 | 412 | return nil 413 | } 414 | -------------------------------------------------------------------------------- /sshesame.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | listen_address: 127.0.0.1:2022 3 | 4 | # Host private key files. 5 | # If unspecified, null or empty, an RSA, ECDSA and Ed25519 key will be generated and stored. 6 | host_keys: null 7 | 8 | # Fake internal services for handling direct-tcpip channels (`ssh -L`). 9 | # If unspecified or null, sensible defaults will be used. 10 | # If empty, no direct-tcpip channels will be accepted. 11 | tcpip_services: 12 | 25: SMTP 13 | 80: HTTP 14 | 110: POP3 15 | 587: SMTP 16 | 8080: HTTP 17 | 18 | logging: 19 | # The log file to output activity logs to. Debug and error logs are still written to standard error. 20 | # If unspecified or null, activity logs are written to standard out. 21 | file: null 22 | 23 | # Make activity logs JSON-formatted instead of human readable. 24 | json: false 25 | 26 | # Include timestamps in the logs. 27 | timestamps: true 28 | 29 | # Log full raw details of all global requests, channels and channel requests. 30 | debug: false 31 | 32 | # Address to export and serve prometheus metrics on. 33 | # If unspecified or null, metrics are not served. 34 | metrics_address: null 35 | 36 | # When logging in JSON, log addresses as objects including the hostname and the port instead of strings. 37 | split_host_port: false 38 | 39 | auth: 40 | # Allow clients to connect without authenticating. 41 | no_auth: false 42 | 43 | # The maximum number of authentication attempts permitted per connection. 44 | # If set to a negative number, the number of attempts are unlimited. 45 | # If unspecified, null or zero, a sensible default is used. 46 | max_tries: 0 47 | 48 | password_auth: 49 | # Offer password authentication as an authentication option. 50 | enabled: true 51 | 52 | # Accept all passwords. 53 | accepted: true 54 | 55 | public_key_auth: 56 | # Offer public key authentication as an authentication option. 57 | enabled: true 58 | 59 | # Accept all public keys. 60 | accepted: false 61 | 62 | keyboard_interactive_auth: 63 | # Offer keyboard interactive authentication as an authentication option. 64 | enabled: false 65 | 66 | # Accept all keyboard interactive answers. 67 | accepted: false 68 | 69 | # Instruction for the keyboard interactive authentication. 70 | instruction: Answer these weird questions to log in! 71 | 72 | questions: 73 | - text: "User: " # Keyboard interactive authentication question text. 74 | echo: true # Enable echoing the answer. 75 | - text: "Password: " 76 | echo: false 77 | 78 | ssh_proto: 79 | # The version identification string to announce in the public handshake. 80 | # If unspecified or null, a reasonable default is used. 81 | # Note that RFC 4253 section 4.2 requires that this string start with "SSH-2.0-". 82 | version: SSH-2.0-sshesame 83 | 84 | # Sent to the client after key exchange completed but before authentication. 85 | # If unspecified or null, a reasonable default is used. 86 | # If empty, no banner is sent. 87 | banner: This is an SSH honeypot. Everything is logged and monitored. 88 | 89 | # The maximum number of bytes sent or received after which a new key is negotiated. It must be at least 256. 90 | # If unspecified, null or 0, a size suitable for the chosen cipher is used. 91 | rekey_threshold: 0 92 | 93 | # The allowed key exchanges algorithms. 94 | # If unspecified or null, a default set of algorithms is used. 95 | key_exchanges: null 96 | 97 | # The allowed cipher algorithms. 98 | # If unspecified or null, a sensible default is used. 99 | ciphers: null 100 | 101 | # The allowed MAC algorithms. 102 | # If unspecified or null, a sensible default is used. 103 | macs: null 104 | -------------------------------------------------------------------------------- /tcpip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httputil" 10 | "strings" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/client_golang/prometheus/promauto" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | type tcpipServer interface { 18 | serve(readWriter io.ReadWriter, input chan<- string) 19 | } 20 | 21 | var servers = map[string]tcpipServer{ 22 | "SMTP": smtpServer{}, 23 | "HTTP": httpServer{}, 24 | "POP3": pop3Server{}, 25 | } 26 | 27 | type tcpipChannelData struct { 28 | Address string 29 | Port uint32 30 | OriginatorAddress string 31 | OriginatorPort uint32 32 | } 33 | 34 | var ( 35 | tcpipChannelsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ 36 | Name: "sshesame_tcpip_channels_total", 37 | Help: "Total number of TCP/IP channels", 38 | }, []string{"service"}) 39 | activeTCPIPChannelsMetric = promauto.NewGaugeVec(prometheus.GaugeOpts{ 40 | Name: "sshesame_active_tcpip_channels", 41 | Help: "Number of active TCP/IP channels", 42 | }, []string{"service"}) 43 | tcpipChannelRequestsMetric = promauto.NewCounterVec(prometheus.CounterOpts{ 44 | Name: "sshesame_tcpip_channel_requests_total", 45 | Help: "Total number of TCP/IP channel requests", 46 | }, []string{"service"}) 47 | ) 48 | 49 | func handleDirectTCPIPChannel(newChannel ssh.NewChannel, context channelContext) error { 50 | channelData := &tcpipChannelData{} 51 | if err := ssh.Unmarshal(newChannel.ExtraData(), channelData); err != nil { 52 | return err 53 | } 54 | service := context.cfg.Server.TCPIPServices[channelData.Port] 55 | server := servers[service] 56 | if server == nil { 57 | tcpipChannelsMetric.WithLabelValues("unknown").Inc() 58 | warningLogger.Printf("Unsupported port %v", channelData.Port) 59 | return newChannel.Reject(ssh.ConnectionFailed, "Connection refused") 60 | } 61 | tcpipChannelsMetric.WithLabelValues(service).Inc() 62 | activeTCPIPChannelsMetric.WithLabelValues(service).Inc() 63 | defer activeTCPIPChannelsMetric.WithLabelValues(service).Dec() 64 | channel, requests, err := newChannel.Accept() 65 | if err != nil { 66 | return err 67 | } 68 | context.logEvent(directTCPIPLog{ 69 | channelLog: channelLog{ 70 | ChannelID: context.channelID, 71 | }, 72 | From: getAddressLog(channelData.OriginatorAddress, int(channelData.OriginatorPort), context.cfg), 73 | To: getAddressLog(channelData.Address, int(channelData.Port), context.cfg), 74 | }) 75 | defer context.logEvent(directTCPIPCloseLog{ 76 | channelLog: channelLog{ 77 | ChannelID: context.channelID, 78 | }, 79 | }) 80 | 81 | inputChan := make(chan string) 82 | go func() { 83 | defer close(inputChan) 84 | server.serve(channel, inputChan) 85 | if err := channel.CloseWrite(); err != nil { 86 | warningLogger.Printf("Error sending EOF to channel: %v", err) 87 | return 88 | } 89 | if err := channel.Close(); err != nil { 90 | warningLogger.Printf("Error closing channel: %v", err) 91 | return 92 | } 93 | }() 94 | 95 | for inputChan != nil || requests != nil { 96 | select { 97 | case input, ok := <-inputChan: 98 | if !ok { 99 | inputChan = nil 100 | continue 101 | } 102 | context.logEvent(directTCPIPInputLog{ 103 | channelLog: channelLog{ 104 | ChannelID: context.channelID, 105 | }, 106 | Input: input, 107 | }) 108 | case request, ok := <-requests: 109 | if !ok { 110 | requests = nil 111 | continue 112 | } 113 | tcpipChannelRequestsMetric.WithLabelValues("unknown").Inc() 114 | context.logEvent(debugChannelRequestLog{ 115 | channelLog: channelLog{ChannelID: context.channelID}, 116 | RequestType: request.Type, 117 | WantReply: request.WantReply, 118 | Payload: string(request.Payload), 119 | }) 120 | warningLogger.Printf("Unsupported direct-tcpip request type %v", request.Type) 121 | if request.WantReply { 122 | if err := request.Reply(false, nil); err != nil { 123 | return err 124 | } 125 | } 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | 132 | type httpServer struct{} 133 | 134 | func (server httpServer) serve(readWriter io.ReadWriter, input chan<- string) { 135 | for { 136 | request, err := http.ReadRequest(bufio.NewReader(readWriter)) 137 | if err != nil { 138 | if err != io.EOF { 139 | warningLogger.Printf("Error reading request: %v", err) 140 | } 141 | return 142 | } 143 | requestBytes, err := httputil.DumpRequest(request, true) 144 | if err != nil { 145 | warningLogger.Printf("Error dumping request: %v", err) 146 | return 147 | } 148 | input <- string(requestBytes) 149 | response := &http.Response{ 150 | StatusCode: 404, 151 | ProtoMajor: 1, 152 | ProtoMinor: 1, 153 | } 154 | responseBytes, err := httputil.DumpResponse(response, true) 155 | if err != nil { 156 | warningLogger.Printf("Error dumping response: %v", err) 157 | return 158 | } 159 | _, err = readWriter.Write(responseBytes) 160 | if err != nil { 161 | warningLogger.Printf("Error writing response: %v", err) 162 | return 163 | } 164 | } 165 | } 166 | 167 | type smtpServer struct{} 168 | 169 | type smtpReply struct { 170 | code int 171 | message string 172 | } 173 | 174 | func (smtpServer) writeReply(writer io.Writer, reply smtpReply) error { 175 | lines := strings.Split(reply.message, "\n") 176 | for _, line := range lines[:len(lines)-1] { 177 | if _, err := fmt.Fprintf(writer, "%d-%s\r\n", reply.code, line); err != nil { 178 | return err 179 | } 180 | } 181 | if _, err := fmt.Fprintf(writer, "%d %s\r\n", reply.code, lines[len(lines)-1]); err != nil { 182 | return err 183 | } 184 | return nil 185 | } 186 | 187 | type smtpCommand struct { 188 | command string 189 | params []string 190 | } 191 | 192 | func (command smtpCommand) String() string { 193 | if len(command.params) == 0 { 194 | return command.command 195 | } 196 | return fmt.Sprintf("%s %s", command.command, strings.Join(command.params, " ")) 197 | } 198 | 199 | func (smtpServer) readCommand(reader io.Reader) (smtpCommand, error) { 200 | line, err := bufio.NewReader(reader).ReadString('\n') 201 | if err != nil { 202 | return smtpCommand{}, err 203 | } 204 | fields := strings.Fields(line) 205 | if len(fields) == 0 { 206 | return smtpCommand{}, fmt.Errorf("empty command") 207 | } 208 | command := strings.ToUpper(fields[0]) 209 | params := fields[1:] 210 | return smtpCommand{command, params}, nil 211 | } 212 | 213 | func (smtpServer) readData(reader io.Reader) (string, error) { 214 | bufioReader := bufio.NewReader(reader) 215 | data := bytes.Buffer{} 216 | crlf := false 217 | for { 218 | line, err := bufioReader.ReadString('\n') 219 | if err != nil { 220 | return "", err 221 | } 222 | data.WriteString(line) 223 | if crlf && line == ".\r\n" { 224 | return data.String(), nil 225 | } 226 | crlf = strings.HasSuffix(line, "\r\n") 227 | } 228 | } 229 | 230 | func (server smtpServer) serve(readWriter io.ReadWriter, input chan<- string) { 231 | if err := server.writeReply(readWriter, smtpReply{220, "localhost"}); err != nil { 232 | warningLogger.Printf("Error writing greeting: %v", err) 233 | return 234 | } 235 | for { 236 | command, err := server.readCommand(readWriter) 237 | if err != nil { 238 | warningLogger.Printf("Error reading command: %v", err) 239 | return 240 | } 241 | input <- command.String() 242 | reply := smtpReply{250, "OK"} 243 | switch command.command { 244 | case "HELO": 245 | case "EHLO": 246 | case "MAIL": 247 | case "RCPT": 248 | case "RSET": 249 | case "DATA": 250 | if err := server.writeReply(readWriter, smtpReply{354, "Start mail input; end with ."}); err != nil { 251 | warningLogger.Printf("Error writing reply: %v", err) 252 | return 253 | } 254 | data, err := server.readData(readWriter) 255 | if err != nil { 256 | warningLogger.Printf("Error reading data: %v", err) 257 | return 258 | } 259 | input <- data 260 | case "QUIT": 261 | reply = smtpReply{221, "Bye!"} 262 | default: 263 | warningLogger.Printf("Unknown SMTP command: %v", command) 264 | reply = smtpReply{500, "unknown command"} 265 | } 266 | if err := server.writeReply(readWriter, reply); err != nil { 267 | warningLogger.Printf("Error writing reply: %v", err) 268 | return 269 | } 270 | if command.command == "QUIT" { 271 | break 272 | } 273 | } 274 | } 275 | 276 | type pop3Server struct{} 277 | 278 | type pop3Response struct { 279 | status bool 280 | message string 281 | multiline bool 282 | } 283 | 284 | func (pop3Server) writeResponse(writer io.Writer, reply pop3Response) error { 285 | lines := strings.Split(reply.message, "\n") 286 | if len(lines) > 1 && !reply.multiline { 287 | return fmt.Errorf("multiline response not allowed") 288 | } 289 | if reply.status { 290 | _, err := fmt.Fprintf(writer, "+OK %s\r\n", lines[0]) 291 | if err != nil { 292 | return err 293 | } 294 | } else { 295 | _, err := fmt.Fprintf(writer, "-ERR %s\r\n", lines[0]) 296 | if err != nil { 297 | return err 298 | } 299 | } 300 | if !reply.multiline { 301 | return nil 302 | } 303 | for _, line := range lines[1:] { 304 | if strings.HasPrefix(line, ".") { 305 | fmt.Fprintf(writer, ".%s\r\n", line) 306 | } else { 307 | fmt.Fprintf(writer, "%s\r\n", line) 308 | } 309 | } 310 | if _, err := fmt.Fprintf(writer, ".\r\n"); err != nil { 311 | return err 312 | } 313 | return nil 314 | } 315 | 316 | type pop3Command struct { 317 | keyword string 318 | args []string 319 | } 320 | 321 | func (command pop3Command) String() string { 322 | if len(command.args) == 0 { 323 | return command.keyword 324 | } 325 | return fmt.Sprintf("%s %s", command.keyword, strings.Join(command.args, " ")) 326 | } 327 | 328 | func (pop3Server) readCommand(reader io.Reader) (pop3Command, error) { 329 | line, err := bufio.NewReader(reader).ReadString('\n') 330 | if err != nil { 331 | return pop3Command{}, err 332 | } 333 | fields := strings.Fields(line) 334 | if len(fields) == 0 { 335 | return pop3Command{}, fmt.Errorf("empty command") 336 | } 337 | keyword := strings.ToUpper(fields[0]) 338 | args := fields[1:] 339 | return pop3Command{keyword, args}, nil 340 | } 341 | 342 | func (server pop3Server) serve(readWriter io.ReadWriter, input chan<- string) { 343 | if err := server.writeResponse(readWriter, pop3Response{true, "localhost", false}); err != nil { 344 | warningLogger.Printf("Error writing greeting: %v", err) 345 | return 346 | } 347 | for { 348 | command, err := server.readCommand(readWriter) 349 | if err != nil { 350 | warningLogger.Printf("Error reading command: %v", err) 351 | return 352 | } 353 | input <- command.String() 354 | var response pop3Response 355 | switch command.keyword { 356 | case "CAPA": 357 | response = pop3Response{true, "Capability list follows", true} 358 | case "LIST": 359 | if len(command.args) == 0 { 360 | response = pop3Response{true, "0 messages", true} 361 | } else { 362 | response = pop3Response{false, "No such message", false} 363 | } 364 | case "QUIT": 365 | response = pop3Response{true, "Bye!", false} 366 | default: 367 | warningLogger.Printf("Unknown POP3 command: %v", command) 368 | response = pop3Response{false, "unknown command", false} 369 | } 370 | if err := server.writeResponse(readWriter, response); err != nil { 371 | warningLogger.Printf("Error writing reply: %v", err) 372 | return 373 | } 374 | if command.keyword == "QUIT" { 375 | break 376 | } 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /testproxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "net" 10 | "os" 11 | 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | type event struct { 16 | Source string `json:"source"` 17 | Type string `json:"type"` 18 | Entry logEntry `json:"entry"` 19 | } 20 | 21 | type source int 22 | 23 | const ( 24 | client source = iota 25 | server 26 | ) 27 | 28 | func (src source) String() string { 29 | switch src { 30 | case client: 31 | return "client" 32 | case server: 33 | return "server" 34 | default: 35 | return "unknown" 36 | } 37 | } 38 | 39 | func (src source) MarshalJSON() ([]byte, error) { 40 | return json.Marshal(src.String()) 41 | } 42 | 43 | type logEntry interface { 44 | eventType() string 45 | } 46 | 47 | type channelLog struct { 48 | ChannelID int `json:"channel_id"` 49 | } 50 | 51 | type requestLog struct { 52 | Type string `json:"type"` 53 | WantReply bool `json:"want_reply"` 54 | Payload string `json:"payload"` 55 | 56 | Accepted bool `json:"accepted"` 57 | } 58 | 59 | type globalRequestLog struct { 60 | requestLog 61 | 62 | Response string `json:"response"` 63 | } 64 | 65 | func (entry globalRequestLog) eventType() string { 66 | return "global_request" 67 | } 68 | 69 | type newChannelLog struct { 70 | Type string `json:"type"` 71 | ExtraData string `json:"extra_data"` 72 | 73 | Accepted bool `json:"accepted"` 74 | RejectReason uint32 `json:"reject_reason"` 75 | Message string `json:"message"` 76 | } 77 | 78 | func (entry newChannelLog) eventType() string { 79 | return "new_channel" 80 | } 81 | 82 | type channelRequestLog struct { 83 | channelLog 84 | requestLog 85 | } 86 | 87 | func (entry channelRequestLog) eventType() string { 88 | return "channel_request" 89 | } 90 | 91 | type channelDataLog struct { 92 | channelLog 93 | Data string `json:"data"` 94 | } 95 | 96 | func (entry channelDataLog) eventType() string { 97 | return "channel_data" 98 | } 99 | 100 | type channelErrorLog struct { 101 | channelLog 102 | Data string `json:"data"` 103 | } 104 | 105 | func (entry channelErrorLog) eventType() string { 106 | return "channel_error" 107 | } 108 | 109 | type channelEOFLog struct { 110 | channelLog 111 | } 112 | 113 | func (entry channelEOFLog) eventType() string { 114 | return "channel_eof" 115 | } 116 | 117 | type channelCloseLog struct { 118 | channelLog 119 | } 120 | 121 | func (entry channelCloseLog) eventType() string { 122 | return "channel_close" 123 | } 124 | 125 | type connectionCloseLog struct{} 126 | 127 | func (entry connectionCloseLog) eventType() string { 128 | return "connection_close" 129 | } 130 | 131 | var output struct { 132 | User string `json:"user"` 133 | Events []event `json:"events"` 134 | PlainLogs []string `json:"plain_logs"` 135 | JSONLogs []map[string]interface{} `json:"json_logs"` 136 | } 137 | 138 | func recordEntry(entry logEntry, src source) { 139 | event := event{ 140 | Source: src.String(), 141 | Type: entry.eventType(), 142 | Entry: entry, 143 | } 144 | output.Events = append(output.Events, event) 145 | } 146 | 147 | func streamReader(reader io.Reader) <-chan string { 148 | input := make(chan string) 149 | go func() { 150 | defer close(input) 151 | buffer := make([]byte, 256) 152 | for { 153 | n, err := reader.Read(buffer) 154 | if n > 0 { 155 | input <- string(buffer[:n]) 156 | } 157 | if err != nil { 158 | if err != io.EOF { 159 | panic(err) 160 | } 161 | return 162 | } 163 | } 164 | }() 165 | return input 166 | } 167 | 168 | func handleChannel(channelID int, clientChannel ssh.Channel, clientRequests <-chan *ssh.Request, serverChannel ssh.Channel, serverRequests <-chan *ssh.Request) { 169 | clientInputStream := streamReader(clientChannel) 170 | serverInputStream := streamReader(serverChannel) 171 | serverErrorStream := streamReader(serverChannel.Stderr()) 172 | 173 | for clientInputStream != nil || clientRequests != nil || serverInputStream != nil || serverRequests != nil { 174 | select { 175 | case clientInput, ok := <-clientInputStream: 176 | if !ok { 177 | if serverInputStream != nil { 178 | recordEntry(channelEOFLog{ 179 | channelLog: channelLog{ 180 | ChannelID: channelID, 181 | }, 182 | }, client) 183 | if err := serverChannel.CloseWrite(); err != nil { 184 | panic(err) 185 | } 186 | } 187 | clientInputStream = nil 188 | continue 189 | } 190 | recordEntry(channelDataLog{ 191 | channelLog: channelLog{ 192 | ChannelID: channelID, 193 | }, 194 | Data: clientInput, 195 | }, client) 196 | if _, err := serverChannel.Write([]byte(clientInput)); err != nil { 197 | panic(err) 198 | } 199 | case clientRequest, ok := <-clientRequests: 200 | if !ok { 201 | if clientInputStream != nil && serverInputStream != nil { 202 | continue 203 | } 204 | if serverRequests != nil { 205 | recordEntry(channelCloseLog{ 206 | channelLog: channelLog{ 207 | ChannelID: channelID, 208 | }, 209 | }, client) 210 | if err := serverChannel.Close(); err != nil { 211 | panic(err) 212 | } 213 | } 214 | clientRequests = nil 215 | continue 216 | } 217 | accepted, err := serverChannel.SendRequest(clientRequest.Type, clientRequest.WantReply, clientRequest.Payload) 218 | if err != nil { 219 | panic(err) 220 | } 221 | recordEntry(channelRequestLog{ 222 | channelLog: channelLog{ 223 | ChannelID: channelID, 224 | }, 225 | requestLog: requestLog{ 226 | Type: clientRequest.Type, 227 | WantReply: clientRequest.WantReply, 228 | Payload: base64.RawStdEncoding.EncodeToString(clientRequest.Payload), 229 | Accepted: accepted, 230 | }, 231 | }, client) 232 | if clientRequest.WantReply { 233 | if err := clientRequest.Reply(accepted, nil); err != nil { 234 | panic(err) 235 | } 236 | } 237 | case serverInput, ok := <-serverInputStream: 238 | if !ok { 239 | if clientInputStream != nil { 240 | recordEntry(channelEOFLog{ 241 | channelLog: channelLog{ 242 | ChannelID: channelID, 243 | }, 244 | }, server) 245 | if err := clientChannel.CloseWrite(); err != nil { 246 | panic(err) 247 | } 248 | } 249 | serverInputStream = nil 250 | continue 251 | } 252 | recordEntry(channelDataLog{ 253 | channelLog: channelLog{ 254 | ChannelID: channelID, 255 | }, 256 | Data: serverInput, 257 | }, server) 258 | if _, err := clientChannel.Write([]byte(serverInput)); err != nil { 259 | panic(err) 260 | } 261 | case serverError, ok := <-serverErrorStream: 262 | if !ok { 263 | serverErrorStream = nil 264 | continue 265 | } 266 | recordEntry(channelErrorLog{ 267 | channelLog: channelLog{ 268 | ChannelID: channelID, 269 | }, 270 | Data: serverError, 271 | }, server) 272 | if _, err := clientChannel.Stderr().Write([]byte(serverError)); err != nil { 273 | panic(err) 274 | } 275 | case serverRequest, ok := <-serverRequests: 276 | if !ok { 277 | if clientInputStream != nil && serverInputStream != nil { 278 | continue 279 | } 280 | if clientRequests != nil { 281 | recordEntry(channelCloseLog{ 282 | channelLog: channelLog{ 283 | ChannelID: channelID, 284 | }, 285 | }, server) 286 | if err := clientChannel.Close(); err != nil { 287 | panic(err) 288 | } 289 | } 290 | serverRequests = nil 291 | continue 292 | } 293 | accepted, err := clientChannel.SendRequest(serverRequest.Type, serverRequest.WantReply, serverRequest.Payload) 294 | if err != nil { 295 | panic(err) 296 | } 297 | recordEntry(channelRequestLog{ 298 | channelLog: channelLog{ 299 | ChannelID: channelID, 300 | }, 301 | requestLog: requestLog{ 302 | Type: serverRequest.Type, 303 | WantReply: serverRequest.WantReply, 304 | Payload: base64.RawStdEncoding.EncodeToString(serverRequest.Payload), 305 | Accepted: accepted, 306 | }, 307 | }, server) 308 | if serverRequest.WantReply { 309 | if err := serverRequest.Reply(accepted, nil); err != nil { 310 | panic(err) 311 | } 312 | } 313 | } 314 | } 315 | } 316 | 317 | func handleConn(clientConn net.Conn, sshServerConfig *ssh.ServerConfig, serverAddress string, clientKey ssh.Signer) { 318 | clientSSHConn, clientNewChannels, clientRequests, err := ssh.NewServerConn(clientConn, sshServerConfig) 319 | if err != nil { 320 | panic(err) 321 | } 322 | 323 | serverConn, err := net.Dial("tcp", serverAddress) 324 | if err != nil { 325 | panic(err) 326 | } 327 | 328 | output.User = clientSSHConn.User() 329 | 330 | serverSSHConn, serverNewChannels, serverRequests, err := ssh.NewClientConn(serverConn, serverAddress, &ssh.ClientConfig{ 331 | User: clientSSHConn.User(), 332 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 333 | Auth: []ssh.AuthMethod{ 334 | ssh.PublicKeys(clientKey), 335 | }, 336 | ClientVersion: "SSH-2.0-OpenSSH_7.2", 337 | }) 338 | if err != nil { 339 | panic(err) 340 | } 341 | 342 | channelID := 0 343 | 344 | for clientNewChannels != nil || clientRequests != nil || serverNewChannels != nil || serverRequests != nil { 345 | select { 346 | case clientNewChannel, ok := <-clientNewChannels: 347 | if !ok { 348 | clientNewChannels = nil 349 | if serverNewChannels != nil { 350 | recordEntry(connectionCloseLog{}, client) 351 | if err := serverSSHConn.Close(); err != nil { 352 | panic(err) 353 | } 354 | } 355 | continue 356 | } 357 | serverChannel, serverChannelRequests, err := serverSSHConn.OpenChannel(clientNewChannel.ChannelType(), clientNewChannel.ExtraData()) 358 | accepted := true 359 | var rejectReason ssh.RejectionReason 360 | var message string 361 | if err != nil { 362 | if openChannelErr, ok := err.(*ssh.OpenChannelError); ok { 363 | accepted = false 364 | rejectReason = openChannelErr.Reason 365 | message = openChannelErr.Message 366 | } else { 367 | panic(err) 368 | } 369 | } 370 | recordEntry(newChannelLog{ 371 | Type: clientNewChannel.ChannelType(), 372 | ExtraData: base64.RawStdEncoding.EncodeToString(clientNewChannel.ExtraData()), 373 | Accepted: accepted, 374 | RejectReason: uint32(rejectReason), 375 | Message: message, 376 | }, client) 377 | if !accepted { 378 | if err := clientNewChannel.Reject(rejectReason, message); err != nil { 379 | panic(err) 380 | } 381 | continue 382 | } 383 | clientChannel, clientChannelRequests, err := clientNewChannel.Accept() 384 | if err != nil { 385 | panic(err) 386 | } 387 | go handleChannel(channelID, clientChannel, clientChannelRequests, serverChannel, serverChannelRequests) 388 | channelID++ 389 | case clientRequest, ok := <-clientRequests: 390 | if !ok { 391 | clientRequests = nil 392 | continue 393 | } 394 | if clientRequest.Type == "no-more-sessions@openssh.com" { 395 | recordEntry(globalRequestLog{ 396 | requestLog: requestLog{ 397 | Type: clientRequest.Type, 398 | WantReply: clientRequest.WantReply, 399 | Payload: base64.RawStdEncoding.EncodeToString(clientRequest.Payload), 400 | Accepted: clientRequest.WantReply, 401 | }, 402 | Response: "", 403 | }, client) 404 | continue 405 | } 406 | accepted, response, err := serverSSHConn.SendRequest(clientRequest.Type, clientRequest.WantReply, clientRequest.Payload) 407 | if err != nil { 408 | panic(err) 409 | } 410 | recordEntry(globalRequestLog{ 411 | requestLog: requestLog{ 412 | Type: clientRequest.Type, 413 | WantReply: clientRequest.WantReply, 414 | Payload: base64.RawStdEncoding.EncodeToString(clientRequest.Payload), 415 | Accepted: accepted, 416 | }, 417 | Response: base64.RawStdEncoding.EncodeToString(response), 418 | }, client) 419 | if err := clientRequest.Reply(accepted, response); err != nil { 420 | panic(err) 421 | } 422 | case serverNewChannel, ok := <-serverNewChannels: 423 | if !ok { 424 | if clientNewChannels != nil { 425 | recordEntry(connectionCloseLog{}, server) 426 | if err := clientSSHConn.Close(); err != nil { 427 | panic(err) 428 | } 429 | } 430 | serverNewChannels = nil 431 | continue 432 | } 433 | panic(serverNewChannel.ChannelType()) 434 | case serverRequest, ok := <-serverRequests: 435 | if !ok { 436 | serverRequests = nil 437 | continue 438 | } 439 | accepted, response, err := clientSSHConn.SendRequest(serverRequest.Type, serverRequest.WantReply, serverRequest.Payload) 440 | recordEntry(globalRequestLog{ 441 | requestLog: requestLog{ 442 | Type: serverRequest.Type, 443 | WantReply: serverRequest.WantReply, 444 | Payload: base64.RawStdEncoding.EncodeToString(serverRequest.Payload), 445 | Accepted: accepted, 446 | }, 447 | Response: base64.RawStdEncoding.EncodeToString(response), 448 | }, server) 449 | if err != nil { 450 | panic(err) 451 | } 452 | if err := serverRequest.Reply(accepted, response); err != nil { 453 | panic(err) 454 | } 455 | } 456 | } 457 | } 458 | 459 | func main() { 460 | listenAddress := flag.String("listen_address", "127.0.0.1:2022", "listen address") 461 | hostKeyFile := flag.String("host_key_file", "", "host key file") 462 | serverAddress := flag.String("server_address", "127.0.0.1:22", "server address") 463 | clientKeyFile := flag.String("client_key_file", "", "client key file") 464 | flag.Parse() 465 | if *listenAddress == "" { 466 | panic("listen address is required") 467 | } 468 | if *hostKeyFile == "" { 469 | panic("host key file is required") 470 | } 471 | if *serverAddress == "" { 472 | panic("server address is required") 473 | } 474 | if *clientKeyFile == "" { 475 | panic("client key file is required") 476 | } 477 | 478 | serverConfig := &ssh.ServerConfig{ 479 | NoClientAuth: true, 480 | ServerVersion: "SSH-2.0-OpenSSH_7.2", 481 | } 482 | hostKeyBytes, err := os.ReadFile(*hostKeyFile) 483 | if err != nil { 484 | panic(err) 485 | } 486 | hostKey, err := ssh.ParsePrivateKey(hostKeyBytes) 487 | if err != nil { 488 | panic(err) 489 | } 490 | serverConfig.AddHostKey(hostKey) 491 | 492 | clientKeyBytes, err := os.ReadFile(*clientKeyFile) 493 | if err != nil { 494 | panic(err) 495 | } 496 | clientKey, err := ssh.ParsePrivateKey(clientKeyBytes) 497 | if err != nil { 498 | panic(err) 499 | } 500 | 501 | listener, err := net.Listen("tcp", *listenAddress) 502 | if err != nil { 503 | panic(err) 504 | } 505 | defer listener.Close() 506 | 507 | conn, err := listener.Accept() 508 | if err != nil { 509 | panic(err) 510 | } 511 | handleConn(conn, serverConfig, *serverAddress, clientKey) 512 | 513 | output.PlainLogs = []string{} 514 | output.JSONLogs = []map[string]interface{}{} 515 | 516 | outputBytes, err := json.MarshalIndent(output, "", " ") 517 | if err != nil { 518 | panic(err) 519 | } 520 | fmt.Println(string(outputBytes)) 521 | } 522 | -------------------------------------------------------------------------------- /testutils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func setupLogBuffer(t *testing.T, cfg *config) *bytes.Buffer { 12 | if err := cfg.setupLogging(); err != nil { 13 | t.Fatalf("Failed to setup logging: %v", err) 14 | } 15 | buffer := &bytes.Buffer{} 16 | log.SetOutput(buffer) 17 | return buffer 18 | } 19 | 20 | // Static keys, only used in tests, to speed them up by not having to generate new keys every time. 21 | const ( 22 | testRSAKey = `-----BEGIN PRIVATE KEY----- 23 | MIIG/wIBADANBgkqhkiG9w0BAQEFAASCBukwggblAgEAAoIBgQCzbvOhNjkuPy3P 24 | twRV64pEgU07dSJqQlxkS/Ame6tK/Ek/6bMPN0/o+7hm7SCse3psh+4oZ7Bay9lk 25 | DGA0/D8Z/OTNlB/kfXfEEPlk02M3ts5o4EULoM+0E9BnE74ZYb0XWwL0tqewuTlT 26 | 2SE13beQ/YXjn96B5+YFHWcBLow5QLLRshax4pVqaPQNyIspfi1p9P8ZirVpa/ia 27 | 62khyrScZbmutqW+bjJ4b1khjRFIdWuMsz+XqfwxSJCxYgdNFOKuLheWr6yIPPYK 28 | iLYltmTJ4Xf62RJtCl6UsYs54Y8Bc57AITzBGw+uQ0QKcLaoBIvAssp9+eycNFJD 29 | baWLZLN345CET3j69b8wwNs9g+GAi1oeFLMabkRUbrk6VPdC48jOAbe3PjM68E4z 30 | iaSLgjHwDICucb0kXlmYALhymTS4VCkLnTNlgYUJDvWMjNs/PT+21C9fiFz/+LXG 31 | lGA5Rjtr/SEPFjtfjiMAtpvgt7xMKWThSiDRzulWF7TRIB8v0e8CAwEAAQKCAYEA 32 | pwfetTBztDBN5fFZkN3tXW38Rh/5BG938EmcaUZwIyKM0XksHTsBIUHJ285bvxRG 33 | 12cF9Qjo6uyeFntKx6gU2Y1INHLx6VI+vf6LGieJUeDTbl9vBq8RCnHzazC+ooQQ 34 | cQBg1Qp/OYyC6CHUv38AlXDbRRSaHdWQkyxWqYv6LoWisH+Wjsr9CgxfO8F2gg6a 35 | Getd2Rn9XACNcTE5MaKv1HMBkbkmuwl75A7LKudVslzT3Cs0RGuRfxMs1mMJpuCL 36 | v5XEnj5LM5xfMDD2eKmJNHKqv/kSYI7T8gaY4SdRP/xNo0X5ltJY+N0XQeZvR6+h 37 | 1y5YgLeL8jv/24FEKWcfr73OcQWzY8zurL5urpELi6HCv07IJeiTy1qgqQZUT1Y1 38 | tMhPKIhie0g43dsrr2OFj0iAN4Pn04mQmYsBAALRqYn5nbv/1tQkwuldov4XM0YH 39 | bqYypSczkPg6DofrmKCJh/Cjzx8pnJNW+65ipn2kq/AAeAHhp3E3Z1IAIKnVQ3I5 40 | AoHBAMnDKU4Rx3+EuLml2dDucrQ1MPpjPdswIt3vhs9mt0SqKoVkjo3hFBQVm8JH 41 | Z9fTQZBtVM4m5LWExaLxN58rDn+FnWB/o1VF5HbO59gM44ExHQGIx2lOr7F+AT4N 42 | mn4kZ3lY64UycvYxGXtRYLkDQLeQcQBHa/noVyhdD1cy+V69gPfstgKeNTKFJb5r 43 | exLPaUihuOzzaG7k+bp7MTiK8O0GQoX4Sv0JweQM4jcSPRQizHJ9HcALjdU9KAqG 44 | deQZJQKBwQDjqylqwfwSYqqb3j8IUkIqb73XP+/DTvmICwYZhEuPj82vyMy+0aGw 45 | RGASsbS0segSp0rL+glMpFp2grMxBGKnQ6JmmCtyFIl3r6Cd/DBR9EXPXfpzjIQo 46 | e97BiAzaQVzcmtl+eVPQXYzoTvw8ervQ4ABBQFqxlAh1Ddo/xv13bN+6iafHBMhl 47 | Ua0ki3Bj9x9/KgB4BVTx+j8Z3cf2PqDhxtPcg1z9Its8K8clTeIKxDjIn8Lbb1hc 48 | v2CbieEq5IMCgcEAhYTbjsiBR0gjnue8j2FdExioQuruAmGGkWxzwEjvO0eJQCFd 49 | nVK4INpz60up0tAA8X0IxCxE6kLlL4GGF5U80PMxRKzy//lyyZT/JKDS5aoE0gEc 50 | RfpGlqUWWWRTOusIdut7YPgT0AyKGmuuIIGgkFnMDi01rXouQ43iGwimsiWidW92 51 | u6DK/5XRdoRWPAp6WBB9+oDSOaDaCqh/2DVKXvDnkRTRO0b7wtkr0toFBZBJz/Iw 52 | f+ilgdoo5144IizpAoHADaYgSIcyroN9yPRtAPm1f8fNMM9jd2kPqqlGh1cYFJZB 53 | dY1rQPFeaSvgOp6uv7p+uEeRQ2NNFWwxBDPXvFOP+okiflYXHLLAfw1narFI0FD9 54 | sm3m6vB8p9StSRr38knC4HLkISHy9WX2YaMCmjmdcutK+J58EXNXgnT/JZ2vam57 55 | hzpjdZoCzZg08iDt7wBMwhnph0iCjDM9fzZ9m3Srvn1mDC1P8NkbHaNeQA1IRO74 56 | nIZ/bxpgyMasawa8Gg8zAoHBAIpgicQn4eR1DexwcUTiPgoXbhqgyirNR31MhfWO 57 | 7ZUla6H+XPqni9u+2FlKZMn2wuooTR746yoUaklcYIncemZZczjU8jz+yXODkGU/ 58 | LnUH0xMlGfEImI8LeuM670I/N2/29SxABsE6RlJULcZrj6jqZKURsC6uYEfKhc5n 59 | dwG0LCp3MvNN4+XvTWBGq2YyNkF43goXG4myV70tkMS8BJGgWT7ID6niuTw8mdUu 60 | qtfwiyotqKKmXeyJWEqJ56B7lw== 61 | -----END PRIVATE KEY-----` 62 | testECDSAKey = `-----BEGIN PRIVATE KEY----- 63 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcPlksxvJRBMC7H70 64 | xGOaGRgody4vm36E2Zm8/UbyVMShRANCAATaM4DFbeUO0JFkUTlUPIhhD9II3391 65 | bdnZwRL5hG1CZw7oVWsCfgm3ujpToKFqAz22AVQees07AV9cICpx+i21 66 | -----END PRIVATE KEY-----` 67 | testEd25519Key = `-----BEGIN PRIVATE KEY----- 68 | MC4CAQAwBQYDK2VwBCIEIIpRrU9cXkMb3/c/H1oAAQpnnS6PXrWJe1jvYo6pSxNw 69 | -----END PRIVATE KEY-----` 70 | ) 71 | 72 | func writeTestKeys(t *testing.T, dataDir string) { 73 | for fileName, content := range map[string]string{ 74 | "host_rsa_key": testRSAKey, 75 | "host_ecdsa_key": testECDSAKey, 76 | "host_ed25519_key": testEd25519Key, 77 | } { 78 | if err := os.WriteFile(filepath.Join(dataDir, fileName), []byte(content), 0600); err != nil { 79 | t.Fatal(err) 80 | } 81 | } 82 | } 83 | --------------------------------------------------------------------------------