diff --git a/README.md b/README.md index 87b3ebc..b90f89e 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 -``` \ No newline at end of file +``` diff --git a/go.mod b/go.mod index 0954cc2..1140e39 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 12100d0..de34996 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/main.go b/main.go index 2cfd560..b74e2b5 100644 --- a/main.go +++ b/main.go @@ -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"` } @@ -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 + 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 { @@ -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()) diff --git a/remote_shell_service.go b/remote_shell_service.go new file mode 100644 index 0000000..a39d367 --- /dev/null +++ b/remote_shell_service.go @@ -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) }), + )) +}