Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ to ensure the Minecraft server is stopped gracefully when the container is sent

## Development Testing

Start a golang container for building and execution:

Start a golang container for building and execution. The port is only needed for remote console functionality:

```bash
docker run -it --rm \
-v ${PWD}:/build \
-w /build \
-p 2222:2222 \
golang:1.19
```

Expand All @@ -42,6 +42,9 @@ Within that container, build/test by running:
```bash
go run . test/dump.sh
go run . test/bash-only.sh
# Used to test remote console functionality
# Connect to this using an ssh client from outside the container to ensure two-way communication works
go run . -remote-console /usr/bin/sh
# The following should fail
go run . --shell sh test/bash-only.sh
```
```
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@ module github.com/itzg/mc-server-runner
go 1.17

require (
github.com/google/uuid v1.5.0
github.com/itzg/go-flagsfiller v1.14.0
github.com/itzg/zapconfigs v0.1.0
go.uber.org/zap v1.26.0
)

require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0 // indirect
)

require (
github.com/gliderlabs/ssh v0.3.6
github.com/iancoleman/strcase v0.3.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gliderlabs/ssh v0.3.6 h1:ZzjlDa05TcFRICb3anf/dSPN3ewz1Zx6CMLPWgkm3b8=
github.com/gliderlabs/ssh v0.3.6/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/itzg/go-flagsfiller v1.14.0 h1:GQOO5Uiy9eQZaJM5f/DjLf3VAn1PNbEHiK/Igv5Qjcc=
Expand Down Expand Up @@ -44,6 +50,8 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
Expand All @@ -58,7 +66,11 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
30 changes: 21 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Args struct {
StopDuration time.Duration `usage:"Amount of time in Golang duration to wait after sending the 'stop' command."`
StopServerAnnounceDelay time.Duration `default:"0s" usage:"Amount of time in Golang duration to wait after announcing server shutdown"`
DetachStdin bool `usage:"Don't forward stdin and allow process to be put in background"`
RemoteConsole bool `usage:"Allow remote shell connections over SSH to server console"`
Shell string `usage:"When set, pass the arguments to this shell"`
NamedPipe string `usage:"Optional path to create and read a named pipe for console input"`
}
Expand Down Expand Up @@ -66,9 +67,15 @@ func main() {
logger.Error("Unable to get stdin", zap.Error(err))
}

// directly assign stdout/err to pass through terminal, if applicable
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Comment on lines -69 to -71
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized this was quite important for server types/mods that detect TTY/console in order to render logs with color codes. Will need to make this behavior conditional on the use of remote console.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjusted defaults in #57

stdout, err := cmd.StdoutPipe()
if err != nil {
logger.Error("Unable to get stdout", zap.Error(err))
}

stderr, err := cmd.StderrPipe()
if err != nil {
logger.Error("Unable to get stderr", zap.Error(err))
}

err = cmd.Start()
if err != nil {
Expand All @@ -86,14 +93,19 @@ func main() {
}
}

console := makeConsole(stdin, stdout, stderr)

// Relay stdin between outside and server
if !args.DetachStdin {
go func() {
_, err := io.Copy(stdin, os.Stdin)
if err != nil {
logger.Error("Failed to relay standard input", zap.Error(err))
}
}()
go consoleInRoutine(os.Stdin, console, logger)
}

go consoleOutRoutine(os.Stdout, console, stdOutTarget, logger)
go consoleOutRoutine(os.Stderr, console, stdErrTarget, logger)

// Start the remote server if intended
if args.RemoteConsole {
go startRemoteShellServer(console, logger)
}

ctx, cancel := context.WithCancel(context.Background())
Expand Down
248 changes: 248 additions & 0 deletions remote_shell_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package main

import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/subtle"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"

"github.com/gliderlabs/ssh"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/term"
)

type ConsoleTarget int32

const (
stdOutTarget ConsoleTarget = 0
stdErrTarget ConsoleTarget = 1
)

type Console struct {
stdInLock sync.Mutex
stdInPipe io.Writer
stdOutPipe io.Reader
stdErrPipe io.Reader

sessionLock sync.Mutex
remoteSessions map[uuid.UUID]ssh.Session
}

func makeConsole(stdin io.Writer, stdout io.Reader, stderr io.Reader) *Console {
return &Console{
stdInPipe: stdin,
stdOutPipe: stdout,
stdErrPipe: stderr,
remoteSessions: map[uuid.UUID]ssh.Session{},
}
}

func (c *Console) OutputPipe(target ConsoleTarget) io.Reader {
switch target {
case stdOutTarget:
return c.stdOutPipe
case stdErrTarget:
return c.stdErrPipe
default:
return c.stdOutPipe
}
}

// Safely write to server's stdin
func (c *Console) WriteToStdIn(p []byte) (n int, err error) {
c.stdInLock.Lock()
n, err = c.stdInPipe.Write(p)
c.stdInLock.Unlock()

return n, err
}

// Register a remote console session for output
func (c *Console) RegisterSession(id uuid.UUID, session ssh.Session) {
c.sessionLock.Lock()
c.remoteSessions[id] = session
c.sessionLock.Unlock()
}

// Deregister a remote console session
func (c *Console) UnregisterSession(id uuid.UUID) {
c.sessionLock.Lock()
delete(c.remoteSessions, id)
c.sessionLock.Unlock()
}

// Fetch current sessions in a thread-safe way
func (c *Console) CurrentSessions() []ssh.Session {
c.sessionLock.Lock()
values := []ssh.Session{}
for _, value := range c.remoteSessions {
values = append(values, value)
}
c.sessionLock.Unlock()

return values
}

func passwordHandler(ctx ssh.Context, password string, logger *zap.Logger) bool {
expectedPassword := os.Getenv("RCON_PASSWORD")
if expectedPassword == "" {
expectedPassword = "minecraft"
}

lengthComp := subtle.ConstantTimeEq(int32(len(password)), int32(len(expectedPassword)))
contentComp := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword))
isValid := lengthComp == 1 && contentComp == 1
if !isValid {
logger.Warn(fmt.Sprintf("Remote console session rejected (%s/%s)", ctx.User(), ctx.RemoteAddr().String()))
}
return isValid
}

func handleSession(session ssh.Session, console *Console, logger *zap.Logger) {
// Setup state for the console session
sessionId := uuid.New()
_, _, isTty := session.Pty()
logger.Info(fmt.Sprintf("Remote console session accepted (%s/%s) isTTY: %t", session.User(), session.RemoteAddr().String(), isTty))
console.RegisterSession(sessionId, session)

// Wrap the session in a terminal so we can read lines.
// Individual lines will be sent to the input channel to be processed as commands for the server.
// If the user sends Ctrl-C/D, this shows up as an EOF and will close the channel.
input := make(chan string)
go func() {
terminal := term.NewTerminal(session, "")
for {
line, err := terminal.ReadLine()
if err != nil {
// Check for client-triggered (expected) exit before logging as an error.
if err != io.EOF {
logger.Error(fmt.Sprintf("Unable to read line from session (%s/%s)", session.User(), session.RemoteAddr().String()), zap.Error(err))
}
close(input)
return
}

input <- line
}
}()

InputLoop:
for {
select {
case line, ok := <-input:
if !ok {
break InputLoop
}

lineBytes := []byte(fmt.Sprintf("%s\n", line))
_, err := console.WriteToStdIn(lineBytes)
if err != nil {
logger.Error(fmt.Sprintf("Session failed to write to stdin (%s/%s)", session.User(), session.RemoteAddr().String()), zap.Error(err))
}
case <-session.Context().Done():
break InputLoop
}
}

// Tear down the session
console.UnregisterSession(sessionId)
logger.Info(fmt.Sprintf("Remote console session disconnected (%s/%s)", session.User(), session.RemoteAddr().String()))
}

// Use stdOut or stdErr for output.
// There should only ever be one at a time per pipe
func consoleOutRoutine(output io.Writer, console *Console, target ConsoleTarget, logger *zap.Logger) {
scanner := bufio.NewScanner(console.OutputPipe(target))
for scanner.Scan() {
outBytes := []byte(fmt.Sprintf("%s\n", scanner.Text()))
_, err := output.Write(outBytes)
if err != nil {
logger.Error("Failed to write to stdout")
}

remoteSessions := console.CurrentSessions()
for _, session := range remoteSessions {
switch target {
case stdOutTarget:
session.Write(outBytes)
case stdErrTarget:
session.Stderr().Write(outBytes)
}
}
}
}

// Use os.Stdin for console.
func consoleInRoutine(stdIn io.Reader, console *Console, logger *zap.Logger) {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
text := scanner.Text()
outBytes := []byte(fmt.Sprintf("%s\n", text))
_, err := console.WriteToStdIn(outBytes)
if err != nil {
logger.Error("Failed to write to stdin")
}
}
}

func ensureHostKey(logger *zap.Logger) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}

keyfilePath := filepath.Join(homeDir, "hostKey.pem")
_, err = os.Stat(keyfilePath)
if os.IsNotExist(err) {
logger.Info("Generating host key for remote shell server.")
hostKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return keyfilePath, err
}

err = hostKey.Validate()
if err != nil {
return keyfilePath, err
}

hostDER := x509.MarshalPKCS1PrivateKey(hostKey)
hostBlock := pem.Block{
Type: "RSA PRIVATE KEY",
Headers: nil,
Bytes: hostDER,
}
hostPEM := pem.EncodeToMemory(&hostBlock)

err = os.WriteFile(keyfilePath, hostPEM, 0600)
return keyfilePath, err
}

return keyfilePath, err
}

func startRemoteShellServer(console *Console, logger *zap.Logger) {
logger.Info("Starting remote shell server on 2222...")
ssh.Handle(func(s ssh.Session) { handleSession(s, console, logger) })

hostKeyPath, err := ensureHostKey(logger)
if err != nil {
logger.Error("Unable to ensure host key exists", zap.Error(err))
return
}

log.Fatal(ssh.ListenAndServe(
":2222",
nil,
ssh.HostKeyFile(hostKeyPath),
ssh.PasswordAuth(func(ctx ssh.Context, password string) bool { return passwordHandler(ctx, password, logger) }),
))
}