├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── named_pipe.go ├── named_pipe_linux.go ├── notes ├── dockerfile-docker-target.png └── dockerfile-run-config.png ├── remote_shell_service.go └── test ├── bash-only.sh ├── dump.sh ├── echo.sh └── exit-with-code.sh /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/go/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Go version: 1, 1.16, 1.17 4 | ARG VARIANT="1.22" 5 | FROM mcr.microsoft.com/vscode/devcontainers/go:1-${VARIANT} 6 | 7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 8 | ARG NODE_VERSION="none" 9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 10 | 11 | # [Optional] Uncomment this section to install additional OS packages. 12 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 13 | # && apt-get -y install --no-install-recommends 14 | 15 | # [Optional] Uncomment the next line to use go get to install anything else you need 16 | # RUN go get -x 17 | 18 | # [Optional] Uncomment this line to install global node packages. 19 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/go 3 | { 4 | "name": "Go", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17 9 | "VARIANT": "1.21" 10 | } 11 | }, 12 | "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 13 | 14 | // Set *default* container specific settings.json values on container create. 15 | "settings": { 16 | "go.toolsManagement.checkForUpdates": "local", 17 | "go.useLanguageServer": true, 18 | "go.gopath": "/go", 19 | "go.goroot": "/usr/local/go" 20 | }, 21 | 22 | // Add the IDs of extensions you want installed when the container is created. 23 | "extensions": [ 24 | "golang.Go" 25 | ], 26 | 27 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 28 | // "forwardPorts": [], 29 | 30 | // Use 'postCreateCommand' to run commands after the container is created. 31 | // "postCreateCommand": "go version", 32 | 33 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 34 | "remoteUser": "vscode" 35 | } 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | github: itzg 3 | custom: 4 | - https://www.buymeacoffee.com/itzg 5 | - https://paypal.me/itzg 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+-*" 8 | 9 | jobs: 10 | release: 11 | uses: itzg/github-workflows/.github/workflows/go-with-releaser-image.yml@main 12 | with: 13 | go-version: "1.23.6" 14 | secrets: 15 | image-registry-username: ${{ secrets.DOCKERHUB_USERNAME }} 16 | image-registry-password: ${{ secrets.DOCKERHUB_TOKEN }} 17 | scoop-tap-github-token: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - README.md 9 | pull_request: 10 | branches: 11 | - master 12 | schedule: 13 | - cron: 0 4 * * SUN 14 | 15 | jobs: 16 | build: 17 | uses: itzg/github-workflows/.github/workflows/go-test.yml@main 18 | with: 19 | go-version: "1.23.6" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /mc-server-runner 2 | /mc-server-runner.exe 3 | /*.iml 4 | /.idea/ 5 | /tmp/ 6 | /.vscode/ -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: mc-server-runner 2 | release: 3 | github: 4 | owner: itzg 5 | name: mc-server-runner 6 | brews: 7 | - install: | 8 | bin.install "mc-server-runner" 9 | builds: 10 | - goos: 11 | - linux 12 | goarch: 13 | - amd64 14 | - arm 15 | - arm64 16 | goarm: 17 | - "6" 18 | - "7" 19 | main: . 20 | env: 21 | - CGO_ENABLED=0 22 | binary: mc-server-runner 23 | archives: 24 | - format_overrides: 25 | - goos: windows 26 | format: zip 27 | changelog: 28 | filters: 29 | exclude: 30 | - '^ci:' 31 | - '^misc:' 32 | - '^docs:' -------------------------------------------------------------------------------- /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 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/itzg/mc-server-runner)](https://github.com/itzg/mc-server-runner/releases/latest) 2 | [![Test](https://github.com/itzg/mc-server-runner/actions/workflows/test.yml/badge.svg)](https://github.com/itzg/mc-server-runner/actions/workflows/test.yml) 3 | 4 | 5 | This is a process wrapper used by 6 | [the itzg/minecraft-server Docker image](https://hub.docker.com/r/itzg/minecraft-server/) 7 | to ensure the Minecraft server is stopped gracefully when the container is sent the `TERM` signal. 8 | 9 | ## Usage 10 | 11 | > Available at any time using `-h` 12 | 13 | ``` 14 | -bootstrap string 15 | Specifies a file with commands to initially send to the server 16 | -debug 17 | Enable debug logging 18 | -detach-stdin 19 | Don't forward stdin and allow process to be put in background 20 | -shell string 21 | When set, pass the arguments to this shell 22 | -stop-duration duration 23 | Amount of time in Golang duration to wait after sending the 'stop' command. 24 | -stop-server-announce-delay duration 25 | Amount of time in Golang duration to wait after announcing server shutdown 26 | ``` 27 | 28 | The `-stop-server-announce-delay` can by bypassed by sending a `SIGUSR1` signal to the `mc-server-runner` process. 29 | This works in cases where a prior `SIGTERM` has already been sent **and** in cases where no prior signal has been sent. 30 | 31 | ## Development Testing 32 | 33 | Start a golang container for building and execution. The port is only needed for remote console functionality: 34 | 35 | ```bash 36 | docker run -it --rm \ 37 | -v ${PWD}:/build \ 38 | -w /build \ 39 | -p 2222:2222 \ 40 | golang:1.19 41 | ``` 42 | 43 | Within that container, build/test by running: 44 | 45 | ```bash 46 | go run . test/dump.sh 47 | go run . test/bash-only.sh 48 | # Used to test remote console functionality 49 | # Connect to this using an ssh client from outside the container to ensure two-way communication works 50 | go run . -remote-console /usr/bin/sh 51 | # The following should fail 52 | go run . --shell sh test/bash-only.sh 53 | ``` 54 | 55 | ### Using the devcontainer's Dockerfile 56 | 57 | #### With IntelliJ 58 | 59 | Create a "Go Build" run configuration 60 | 61 | ![](notes/dockerfile-run-config.png) 62 | 63 | with a Dockerfile target 64 | 65 | ![](notes/dockerfile-docker-target.png) 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itzg/mc-server-runner 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 7 | github.com/itzg/go-flagsfiller v1.15.0 8 | github.com/itzg/zapconfigs v0.1.0 9 | go.uber.org/zap v1.27.0 10 | golang.org/x/crypto v0.38.0 11 | golang.org/x/term v0.32.0 12 | ) 13 | 14 | require ( 15 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 16 | golang.org/x/sys v0.33.0 // indirect 17 | ) 18 | 19 | require ( 20 | github.com/gliderlabs/ssh v0.3.8 21 | github.com/iancoleman/strcase v0.3.0 // indirect 22 | go.uber.org/multierr v1.10.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 3 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 8 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 9 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 10 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 11 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 13 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 14 | github.com/itzg/go-flagsfiller v1.15.0 h1:xspqfbiifTo1qnCpExtfkMN5fSfueB0nMsOsazcTETw= 15 | github.com/itzg/go-flagsfiller v1.15.0/go.mod h1:nR3jrF1gVJ7ZUfSews6/oPbXjBTG3ziIHfLaXstmxjE= 16 | github.com/itzg/zapconfigs v0.1.0 h1:Gokocm8VaTNnZjvIiVA5NEhzZ1v7lEyXY/AbeBmq6YQ= 17 | github.com/itzg/zapconfigs v0.1.0/go.mod h1:y4dArgRUOFbGRkUNJ8XSSw98FGn03wtkvMPy+OSA5Rc= 18 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 19 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 28 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 29 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 30 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 31 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 32 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 33 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 34 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 35 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 36 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 37 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 38 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 39 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 40 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 41 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 42 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 43 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 44 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 45 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 46 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 47 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 48 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 49 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 52 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 54 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 55 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 56 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 57 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 58 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 59 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 60 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 61 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 62 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 66 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 67 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "os/signal" 13 | "strings" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/itzg/go-flagsfiller" 18 | "github.com/itzg/zapconfigs" 19 | "go.uber.org/zap" 20 | ) 21 | 22 | type Args struct { 23 | Debug bool `usage:"Enable debug logging"` 24 | Bootstrap string `usage:"Specifies a file with commands to initially send to the server"` 25 | StopCommand string `default:"stop" usage:"Which command to send to the server to stop it"` 26 | StopDuration time.Duration `usage:"Amount of time in Golang duration to wait after sending the 'stop' command."` 27 | StopServerAnnounceDelay time.Duration `default:"0s" usage:"Amount of time in Golang duration to wait after announcing server shutdown"` 28 | DetachStdin bool `usage:"Don't forward stdin and allow process to be put in background"` 29 | RemoteConsole bool `usage:"Allow remote shell connections over SSH to server console"` 30 | Shell string `usage:"When set, pass the arguments to this shell"` 31 | NamedPipe string `usage:"Optional path to create and read a named pipe for console input"` 32 | } 33 | 34 | func main() { 35 | // docker stop sends a SIGTERM, so intercept that and send a 'stop' command to the server 36 | termChan := make(chan os.Signal, 1) 37 | signal.Notify(termChan, syscall.SIGTERM) 38 | 39 | // Additionally intercept SIGUSR1 which bypasses StopServerAnnounceDelay (in cases where SIGTERM has **and** hasn't been sent) 40 | usr1Chan := make(chan os.Signal, 1) 41 | signal.Notify(usr1Chan, syscall.SIGUSR1) 42 | 43 | var args Args 44 | err := flagsfiller.Parse(&args) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | var logger *zap.Logger 50 | if args.Debug { 51 | logger = zapconfigs.NewDebugLogger() 52 | } else { 53 | logger = zapconfigs.NewDefaultLogger() 54 | } 55 | //goland:noinspection GoUnhandledErrorResult 56 | defer logger.Sync() 57 | logger = logger.Named("mc-server-runner") 58 | 59 | var cmd *exec.Cmd 60 | 61 | if flag.NArg() < 1 { 62 | logger.Fatal("Missing executable arguments") 63 | } 64 | 65 | if args.Shell != "" { 66 | cmd = exec.Command(args.Shell, flag.Args()...) 67 | } else { 68 | cmd = exec.Command(flag.Arg(0), flag.Args()[1:]...) 69 | } 70 | 71 | stdin, err := cmd.StdinPipe() 72 | if err != nil { 73 | logger.Error("Unable to get stdin", zap.Error(err)) 74 | } 75 | 76 | if args.RemoteConsole { 77 | stdout, err := cmd.StdoutPipe() 78 | if err != nil { 79 | logger.Error("Unable to get stdout", zap.Error(err)) 80 | } 81 | 82 | stderr, err := cmd.StderrPipe() 83 | if err != nil { 84 | logger.Error("Unable to get stderr", zap.Error(err)) 85 | } 86 | 87 | console := makeConsole(stdin, stdout, stderr) 88 | 89 | // Relay stdin between outside and server 90 | if !args.DetachStdin { 91 | go consoleInRoutine(os.Stdin, console, logger) 92 | } 93 | 94 | go consoleOutRoutine(os.Stdout, console, stdOutTarget, logger) 95 | go consoleOutRoutine(os.Stderr, console, stdErrTarget, logger) 96 | 97 | go runRemoteShellServer(console, logger) 98 | 99 | logger.Info("Running with remote console support") 100 | } else { 101 | logger.Debug("Directly assigning stdout/stderr") 102 | // directly assign stdout/err to pass through terminal, if applicable 103 | cmd.Stdout = os.Stdout 104 | cmd.Stderr = os.Stderr 105 | 106 | if hasRconCli() && args.NamedPipe == "" { 107 | logger.Debug("Directly assigning stdin") 108 | cmd.Stdin = os.Stdin 109 | stdin = os.Stdin 110 | } else { 111 | go relayStdin(logger, stdin) 112 | } 113 | } 114 | 115 | err = cmd.Start() 116 | if err != nil { 117 | logger.Error("Failed to start", zap.Error(err)) 118 | } 119 | 120 | if args.Bootstrap != "" { 121 | bootstrapContent, err := os.ReadFile(args.Bootstrap) 122 | if err != nil { 123 | logger.Error("Failed to read bootstrap commands", zap.Error(err)) 124 | } 125 | _, err = stdin.Write(bootstrapContent) 126 | if err != nil { 127 | logger.Error("Failed to write bootstrap content", zap.Error(err)) 128 | } 129 | } 130 | 131 | ctx, cancel := context.WithCancel(context.Background()) 132 | errorChan := make(chan error, 1) 133 | 134 | if args.NamedPipe != "" { 135 | err2 := handleNamedPipe(ctx, args.NamedPipe, stdin, errorChan) 136 | if err2 != nil { 137 | logger.Fatal("Failed to setup named pipe", zap.Error(err2)) 138 | } 139 | } 140 | 141 | cmdExitChan := make(chan int, 1) 142 | 143 | go func() { 144 | waitErr := cmd.Wait() 145 | if waitErr != nil { 146 | var exitErr *exec.ExitError 147 | if errors.As(waitErr, &exitErr) { 148 | exitCode := exitErr.ExitCode() 149 | logger.Warn("Minecraft server failed. Inspect logs above for errors that indicate cause. DO NOT report this line as an error.", 150 | zap.Int("exitCode", exitCode)) 151 | cmdExitChan <- exitCode 152 | } 153 | return 154 | } else { 155 | cmdExitChan <- 0 156 | } 157 | }() 158 | 159 | var timer *time.Timer 160 | 161 | for { 162 | select { 163 | case <-termChan: 164 | logger.Debug("SIGTERM caught") 165 | logger.Info("gracefully stopping server...") 166 | if args.StopServerAnnounceDelay > 0 { 167 | announceStop(logger, stdin, args.StopServerAnnounceDelay) 168 | logger.Info("Sleeping before server stop", zap.Duration("sleepTime", args.StopServerAnnounceDelay)) 169 | timer = time.AfterFunc(args.StopServerAnnounceDelay, func() { 170 | logger.Info("StopServerAnnounceDelay elapsed, stopping server") 171 | terminate(logger, stdin, cmd, args.StopDuration, args.StopCommand) 172 | }) 173 | } else { 174 | terminate(logger, stdin, cmd, args.StopDuration, args.StopCommand) 175 | } 176 | 177 | case <-usr1Chan: 178 | if timer != nil { 179 | if timer.Stop() { 180 | logger.Info("SIGUSR1 caught, bypassing running StopServerAnnounceDelay") 181 | terminate(logger, stdin, cmd, args.StopDuration, args.StopCommand) 182 | } else { 183 | logger.Info("SIGUSR1 caught, StopServerAnnounceDelay already elapsed, server is already stopping") 184 | } 185 | } else { 186 | logger.Info("SIGUSR1 caught, gracefully stopping server... (without StopServerAnnounceDelay)") 187 | terminate(logger, stdin, cmd, args.StopDuration, args.StopCommand) 188 | } 189 | 190 | case namedPipeErr := <-errorChan: 191 | logger.Error("Error during named pipe handling", zap.Error(namedPipeErr)) 192 | 193 | case exitCode := <-cmdExitChan: 194 | cancel() 195 | logger.Info("Done") 196 | os.Exit(exitCode) 197 | } 198 | } 199 | 200 | } 201 | 202 | func relayStdin(logger *zap.Logger, stdin io.WriteCloser) { 203 | _, err := io.Copy(stdin, os.Stdin) 204 | if err != nil { 205 | logger.Error("Failed to relay standard input", zap.Error(err)) 206 | } 207 | } 208 | 209 | func hasRconCli() bool { 210 | if strings.ToUpper(os.Getenv("ENABLE_RCON")) == "TRUE" { 211 | _, err := exec.LookPath("rcon-cli") 212 | return err == nil 213 | } else { 214 | return false 215 | } 216 | } 217 | 218 | func sendRconCommand(cmd ...string) error { 219 | rconConfigFile := os.Getenv("RCON_CONFIG_FILE") 220 | if rconConfigFile == "" { 221 | port := os.Getenv("RCON_PORT") 222 | if port == "" { 223 | port = "25575" 224 | } 225 | 226 | password := os.Getenv("RCON_PASSWORD") 227 | if password == "" { 228 | password = "minecraft" 229 | } 230 | 231 | args := []string{"--port", port, 232 | "--password", password} 233 | args = append(args, cmd...) 234 | 235 | rconCliCmd := exec.Command("rcon-cli", args...) 236 | 237 | return rconCliCmd.Run() 238 | } else { 239 | 240 | args := []string{"--config", rconConfigFile} 241 | args = append(args, cmd...) 242 | 243 | rconCliCmd := exec.Command("rcon-cli", args...) 244 | 245 | return rconCliCmd.Run() 246 | } 247 | } 248 | 249 | // sendCommand will send the given command via RCON when available, otherwise it will write to the given stdin 250 | func sendCommand(stdin io.Writer, cmd ...string) error { 251 | if hasRconCli() { 252 | return sendRconCommand(cmd...) 253 | } else { 254 | _, err := stdin.Write([]byte(strings.Join(cmd, " "))) 255 | return err 256 | } 257 | } 258 | 259 | // terminate sends `stop` to the server and kill process once stopDuration elapsed 260 | func terminate(logger *zap.Logger, stdin io.Writer, cmd *exec.Cmd, stopDuration time.Duration, stopCommand string) { 261 | if stopCommand == "" { 262 | stopCommand = "stop" 263 | } 264 | if hasRconCli() { 265 | err := stopWithRconCli(stopCommand) 266 | if err != nil { 267 | logger.Error("Failed to stop using rcon-cli", zap.Error(err)) 268 | stopViaConsole(logger, stdin, stopCommand) 269 | } 270 | } else { 271 | stopViaConsole(logger, stdin, stopCommand) 272 | } 273 | 274 | logger.Info("Waiting for completion...") 275 | if stopDuration != 0 { 276 | time.AfterFunc(stopDuration, func() { 277 | logger.Error("Took too long, so killing server process") 278 | err := cmd.Process.Kill() 279 | if err != nil { 280 | logger.Error("Failed to forcefully kill process") 281 | } 282 | }) 283 | } 284 | } 285 | 286 | func announceStop(logger *zap.Logger, stdin io.Writer, shutdownDelay time.Duration) { 287 | logger.Info("Sending shutdown announce 'say' to Minecraft server") 288 | 289 | err := sendCommand(stdin, "say", fmt.Sprintf("Server shutting down in %0.f seconds", shutdownDelay.Seconds())) 290 | if err != nil { 291 | logger.Error("Failed to send 'say' command", zap.Error(err)) 292 | } 293 | } 294 | 295 | func stopWithRconCli(stopCommand string) error { 296 | log.Println("Stopping with rcon-cli") 297 | 298 | return sendRconCommand(stopCommand) 299 | } 300 | 301 | func stopViaConsole(logger *zap.Logger, stdin io.Writer, stopCommand string) { 302 | logger.Info("Sending '" + stopCommand + "' to Minecraft server...") 303 | _, err := stdin.Write([]byte(stopCommand + "\n")) 304 | if err != nil { 305 | logger.Error("Failed to write stop command to server console", zap.Error(err)) 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /named_pipe.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "io" 9 | ) 10 | 11 | func handleNamedPipe(ctx context.Context, path string, stdin io.Writer, errors chan error) error { 12 | // does nothing on non-linux 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /named_pipe_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | func handleNamedPipe(ctx context.Context, path string, stdin io.Writer, errors chan error) error { 12 | fi, statErr := os.Stat(path) 13 | if statErr != nil { 14 | if os.IsNotExist(statErr) { 15 | mkErr := syscall.Mkfifo(path, 0666) 16 | if mkErr != nil { 17 | return fmt.Errorf("failed to create named pipe: %w", mkErr) 18 | } 19 | } else { 20 | return fmt.Errorf("failed to stat named pipe: %w", statErr) 21 | } 22 | } else { 23 | // already exists...named pipe? 24 | if fi.Mode().Type()&os.ModeNamedPipe == 0 { 25 | return fmt.Errorf("existing path '%s' is not a named pipe", path) 26 | } 27 | } 28 | 29 | go func() { 30 | //goland:noinspection GoUnhandledErrorResult 31 | defer os.Remove(path) 32 | 33 | for { 34 | select { 35 | case <-ctx.Done(): 36 | return 37 | 38 | default: 39 | f, openErr := os.Open(path) 40 | if openErr != nil { 41 | errors <- fmt.Errorf("failed to open named fifo: %w", openErr) 42 | return 43 | } 44 | 45 | _, copyErr := io.Copy(stdin, f) 46 | if copyErr != nil { 47 | errors <- fmt.Errorf("unexpected error reading named pipe: %w", copyErr) 48 | } 49 | f.Close() 50 | } 51 | } 52 | }() 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /notes/dockerfile-docker-target.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzg/mc-server-runner/15040a9b66b5ecf436746b499e00ac1355ff6831/notes/dockerfile-docker-target.png -------------------------------------------------------------------------------- /notes/dockerfile-run-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzg/mc-server-runner/15040a9b66b5ecf436746b499e00ac1355ff6831/notes/dockerfile-run-config.png -------------------------------------------------------------------------------- /remote_shell_service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "crypto/subtle" 10 | "crypto/x509" 11 | "encoding/pem" 12 | "fmt" 13 | "io" 14 | "log" 15 | "os" 16 | "path/filepath" 17 | "sync" 18 | 19 | "github.com/gliderlabs/ssh" 20 | "github.com/google/uuid" 21 | "go.uber.org/zap" 22 | gossh "golang.org/x/crypto/ssh" 23 | "golang.org/x/term" 24 | ) 25 | 26 | type ConsoleTarget int32 27 | 28 | const ( 29 | RSAKeyType string = "RSA PRIVATE KEY" 30 | ECKeyType = "EC PRIVATE KEY" 31 | ) 32 | 33 | const ( 34 | stdOutTarget ConsoleTarget = 0 35 | stdErrTarget ConsoleTarget = 1 36 | ) 37 | 38 | type Console struct { 39 | stdInLock sync.Mutex 40 | stdInPipe io.Writer 41 | stdOutPipe io.Reader 42 | stdErrPipe io.Reader 43 | 44 | sessionLock sync.Mutex 45 | remoteSessions map[uuid.UUID]ssh.Session 46 | } 47 | 48 | func makeConsole(stdin io.Writer, stdout io.Reader, stderr io.Reader) *Console { 49 | return &Console{ 50 | stdInPipe: stdin, 51 | stdOutPipe: stdout, 52 | stdErrPipe: stderr, 53 | remoteSessions: map[uuid.UUID]ssh.Session{}, 54 | } 55 | } 56 | 57 | func (c *Console) OutputPipe(target ConsoleTarget) io.Reader { 58 | switch target { 59 | case stdOutTarget: 60 | return c.stdOutPipe 61 | case stdErrTarget: 62 | return c.stdErrPipe 63 | default: 64 | return c.stdOutPipe 65 | } 66 | } 67 | 68 | // Safely write to server's stdin 69 | func (c *Console) WriteToStdIn(p []byte) (n int, err error) { 70 | c.stdInLock.Lock() 71 | n, err = c.stdInPipe.Write(p) 72 | c.stdInLock.Unlock() 73 | 74 | return n, err 75 | } 76 | 77 | // Register a remote console session for output 78 | func (c *Console) RegisterSession(id uuid.UUID, session ssh.Session) { 79 | c.sessionLock.Lock() 80 | c.remoteSessions[id] = session 81 | c.sessionLock.Unlock() 82 | } 83 | 84 | // Deregister a remote console session 85 | func (c *Console) UnregisterSession(id uuid.UUID) { 86 | c.sessionLock.Lock() 87 | delete(c.remoteSessions, id) 88 | c.sessionLock.Unlock() 89 | } 90 | 91 | // Fetch current sessions in a thread-safe way 92 | func (c *Console) CurrentSessions() []ssh.Session { 93 | c.sessionLock.Lock() 94 | values := []ssh.Session{} 95 | for _, value := range c.remoteSessions { 96 | values = append(values, value) 97 | } 98 | c.sessionLock.Unlock() 99 | 100 | return values 101 | } 102 | 103 | func passwordHandler(ctx ssh.Context, password string, logger *zap.Logger) bool { 104 | expectedPassword := os.Getenv("RCON_PASSWORD") 105 | if expectedPassword == "" { 106 | expectedPassword = "minecraft" 107 | } 108 | 109 | lengthComp := subtle.ConstantTimeEq(int32(len(password)), int32(len(expectedPassword))) 110 | contentComp := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) 111 | isValid := lengthComp == 1 && contentComp == 1 112 | if !isValid { 113 | logger.Warn(fmt.Sprintf("Remote console session rejected (%s/%s)", ctx.User(), ctx.RemoteAddr().String())) 114 | } 115 | return isValid 116 | } 117 | 118 | func handleSession(session ssh.Session, console *Console, logger *zap.Logger) { 119 | // Setup state for the console session 120 | sessionId := uuid.New() 121 | _, _, isTty := session.Pty() 122 | logger.Info(fmt.Sprintf("Remote console session accepted (%s/%s) isTTY: %t", session.User(), session.RemoteAddr().String(), isTty)) 123 | console.RegisterSession(sessionId, session) 124 | 125 | // Wrap the session in a terminal so we can read lines. 126 | // Individual lines will be sent to the input channel to be processed as commands for the server. 127 | // If the user sends Ctrl-C/D, this shows up as an EOF and will close the channel. 128 | input := make(chan string) 129 | go func() { 130 | terminal := term.NewTerminal(session, "") 131 | for { 132 | line, err := terminal.ReadLine() 133 | if err != nil { 134 | // Check for client-triggered (expected) exit before logging as an error. 135 | if err != io.EOF { 136 | logger.Error(fmt.Sprintf("Unable to read line from session (%s/%s)", session.User(), session.RemoteAddr().String()), zap.Error(err)) 137 | } 138 | close(input) 139 | return 140 | } 141 | 142 | input <- line 143 | } 144 | }() 145 | 146 | InputLoop: 147 | for { 148 | select { 149 | case line, ok := <-input: 150 | if !ok { 151 | break InputLoop 152 | } 153 | 154 | lineBytes := []byte(fmt.Sprintf("%s\n", line)) 155 | _, err := console.WriteToStdIn(lineBytes) 156 | if err != nil { 157 | logger.Error(fmt.Sprintf("Session failed to write to stdin (%s/%s)", session.User(), session.RemoteAddr().String()), zap.Error(err)) 158 | } 159 | case <-session.Context().Done(): 160 | break InputLoop 161 | } 162 | } 163 | 164 | // Tear down the session 165 | console.UnregisterSession(sessionId) 166 | logger.Info(fmt.Sprintf("Remote console session disconnected (%s/%s)", session.User(), session.RemoteAddr().String())) 167 | } 168 | 169 | // Use stdOut or stdErr for output. 170 | // There should only ever be one at a time per pipe 171 | func consoleOutRoutine(output io.Writer, console *Console, target ConsoleTarget, logger *zap.Logger) { 172 | scanner := bufio.NewScanner(console.OutputPipe(target)) 173 | for scanner.Scan() { 174 | outBytes := []byte(fmt.Sprintf("%s\n", scanner.Text())) 175 | _, err := output.Write(outBytes) 176 | if err != nil { 177 | logger.Error("Failed to write to stdout") 178 | } 179 | 180 | remoteSessions := console.CurrentSessions() 181 | for _, session := range remoteSessions { 182 | switch target { 183 | case stdOutTarget: 184 | session.Write(outBytes) 185 | case stdErrTarget: 186 | session.Stderr().Write(outBytes) 187 | } 188 | } 189 | } 190 | } 191 | 192 | // Use os.Stdin for console. 193 | func consoleInRoutine(stdIn io.Reader, console *Console, logger *zap.Logger) { 194 | scanner := bufio.NewScanner(stdIn) 195 | for scanner.Scan() { 196 | text := scanner.Text() 197 | outBytes := []byte(fmt.Sprintf("%s\n", text)) 198 | _, err := console.WriteToStdIn(outBytes) 199 | if err != nil { 200 | logger.Error("Failed to write to stdin") 201 | } 202 | } 203 | } 204 | 205 | const ( 206 | // Current filename, hides on Linux systems. 207 | HostKeyFilename string = ".hostKey.pem" 208 | 209 | // Old filename, not hidden. 210 | OldHostKeyFilename = "hostKey.pem" 211 | ) 212 | 213 | // Use the hidden form first, but fallback to the non-hidden one if it already exists. 214 | func pickHostKeyPath(homeDir string) string { 215 | defaultKeyfilePath := filepath.Join(homeDir, HostKeyFilename) 216 | _, err := os.Stat(defaultKeyfilePath) 217 | if !os.IsNotExist(err) { 218 | return defaultKeyfilePath 219 | } 220 | 221 | fallbackKeyfilePath := filepath.Join(homeDir, OldHostKeyFilename) 222 | _, err = os.Stat(fallbackKeyfilePath) 223 | if !os.IsNotExist(err) { 224 | return fallbackKeyfilePath 225 | } 226 | 227 | return defaultKeyfilePath 228 | } 229 | 230 | // Exists to clean up the non-hidden key file if it still exists 231 | func cleanupOldHostKey() error { 232 | homeDir, err := os.UserHomeDir() 233 | if err != nil { 234 | return err 235 | } 236 | 237 | keyfilePath := filepath.Join(homeDir, OldHostKeyFilename) 238 | _, err = os.Stat(keyfilePath) 239 | if os.IsNotExist(err) { 240 | return nil 241 | } 242 | 243 | err = os.Remove(keyfilePath) 244 | if err != nil { 245 | return err 246 | } 247 | 248 | _, err = os.Stat(keyfilePath) 249 | if !os.IsNotExist(err) { 250 | return err 251 | } 252 | 253 | return nil 254 | } 255 | 256 | type hostKeys struct { 257 | rsaKey *rsa.PrivateKey 258 | ecKey *ecdsa.PrivateKey 259 | } 260 | 261 | func populateKeys(keys *hostKeys, logger *zap.Logger) (bool, error) { 262 | didAdd := false 263 | if keys.ecKey == nil { 264 | logger.Info("Generating ECDSA SSH Host Key") 265 | ellipticKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 266 | if err != nil { 267 | return didAdd, err 268 | } 269 | 270 | keys.ecKey = ellipticKey 271 | didAdd = true 272 | } 273 | 274 | if keys.rsaKey == nil { 275 | logger.Info("Generating RSA SSH Host Key") 276 | rsaKey, err := rsa.GenerateKey(rand.Reader, 4096) 277 | if err != nil { 278 | return didAdd, err 279 | } 280 | 281 | keys.rsaKey = rsaKey 282 | didAdd = true 283 | } 284 | 285 | return didAdd, nil 286 | } 287 | 288 | func writeKeys(hostKeyPath string, keys *hostKeys, logger *zap.Logger) error { 289 | keysFile, err := os.OpenFile(hostKeyPath, os.O_CREATE+os.O_WRONLY+os.O_TRUNC, 0600) 290 | if err != nil { 291 | return err 292 | } 293 | 294 | defer keysFile.Close() 295 | 296 | logger.Info(fmt.Sprintf("Writing Host Keys to %s.", hostKeyPath)) 297 | if keys.ecKey != nil { 298 | ecDER, err := x509.MarshalECPrivateKey(keys.ecKey) 299 | if err != nil { 300 | return err 301 | } 302 | 303 | ecBlock := pem.Block{ 304 | Type: ECKeyType, 305 | Bytes: ecDER, 306 | } 307 | 308 | pem.Encode(keysFile, &ecBlock) 309 | } 310 | 311 | if keys.rsaKey != nil { 312 | rsaDER := x509.MarshalPKCS1PrivateKey(keys.rsaKey) 313 | rsaBlock := pem.Block{ 314 | Type: RSAKeyType, 315 | Bytes: rsaDER, 316 | } 317 | 318 | pem.Encode(keysFile, &rsaBlock) 319 | } 320 | 321 | return nil 322 | } 323 | 324 | func readKeys(hostKeyPath string) (*hostKeys, error) { 325 | bytes, err := os.ReadFile(hostKeyPath) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | var keys hostKeys 331 | for len(bytes) > 0 { 332 | pemBlock, next := pem.Decode(bytes) 333 | if pemBlock == nil { 334 | break 335 | } 336 | 337 | switch pemBlock.Type { 338 | case RSAKeyType: 339 | rsaKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes) 340 | if err != nil { 341 | return &keys, err 342 | } 343 | keys.rsaKey = rsaKey 344 | case ECKeyType: 345 | ecKey, err := x509.ParseECPrivateKey(pemBlock.Bytes) 346 | if err != nil { 347 | return &keys, err 348 | } 349 | keys.ecKey = ecKey 350 | } 351 | 352 | bytes = next 353 | } 354 | 355 | return &keys, nil 356 | } 357 | 358 | func ensureHostKeys(logger *zap.Logger) (*hostKeys, error) { 359 | homeDir, err := os.UserHomeDir() 360 | if err != nil { 361 | return nil, err 362 | } 363 | 364 | keyfilePath := pickHostKeyPath(homeDir) 365 | defaultKeyfilePath := filepath.Join(homeDir, HostKeyFilename) 366 | fileChanged := keyfilePath != defaultKeyfilePath 367 | _, err = os.Stat(keyfilePath) 368 | if os.IsNotExist(err) { 369 | logger.Info("Generating host keys for remote shell server.") 370 | var hostKeys hostKeys 371 | addedKeys, err := populateKeys(&hostKeys, logger) 372 | 373 | if (fileChanged || addedKeys) && err == nil { 374 | writeKeys(defaultKeyfilePath, &hostKeys, logger) 375 | } 376 | return &hostKeys, err 377 | } else { 378 | logger.Info(fmt.Sprintf("Reading host keys for remote shell from %s.", keyfilePath)) 379 | hostKeys, err := readKeys(keyfilePath) 380 | if err != nil { 381 | return nil, err 382 | } 383 | 384 | // Populate missing keys (older files only have RSA) 385 | addedKeys, err := populateKeys(hostKeys, logger) 386 | 387 | if (fileChanged || addedKeys) && err == nil { 388 | writeKeys(defaultKeyfilePath, hostKeys, logger) 389 | } 390 | return hostKeys, err 391 | } 392 | } 393 | 394 | func twinKeys(keys *hostKeys) ssh.Option { 395 | return func(srv *ssh.Server) error { 396 | rsaSigner, err := gossh.NewSignerFromKey(keys.rsaKey) 397 | if err != nil { 398 | return err 399 | } 400 | srv.AddHostKey(rsaSigner) 401 | 402 | ecSigner, err := gossh.NewSignerFromKey(keys.ecKey) 403 | if err != nil { 404 | return err 405 | } 406 | srv.AddHostKey(ecSigner) 407 | 408 | return nil 409 | } 410 | } 411 | 412 | func runRemoteShellServer(console *Console, logger *zap.Logger) { 413 | logger.Info("Starting remote shell server on 2222...") 414 | ssh.Handle(func(s ssh.Session) { handleSession(s, console, logger) }) 415 | 416 | hostKeys, err := ensureHostKeys(logger) 417 | if err != nil { 418 | logger.Error("Unable to ensure host keys exist", zap.Error(err)) 419 | return 420 | } 421 | 422 | err = cleanupOldHostKey() 423 | if err != nil { 424 | logger.Warn("Unable to remote old host key file", zap.Error(err)) 425 | } 426 | 427 | log.Fatal(ssh.ListenAndServe( 428 | ":2222", 429 | nil, 430 | twinKeys(hostKeys), 431 | ssh.PasswordAuth(func(ctx ssh.Context, password string) bool { return passwordHandler(ctx, password, logger) }), 432 | )) 433 | } 434 | -------------------------------------------------------------------------------- /test/bash-only.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC2050 4 | if [[ good = good ]]; then 5 | echo "I am bash" 6 | fi -------------------------------------------------------------------------------- /test/dump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | env -------------------------------------------------------------------------------- /test/echo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script can be used to test the --named-pipe feature 4 | 5 | echo "Ready to echo stdin..." 6 | cat -------------------------------------------------------------------------------- /test/exit-with-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit "$1" 4 | --------------------------------------------------------------------------------