diff --git a/README.md b/README.md index 2462b06..bb7bdfb 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,13 @@ # tcprcon - [tcprcon](#tcprcon) - - [Features](#features) - - [Installation](#installation) - - [Usage](#usage) - - [Interactive Mode](#interactive-mode) - - [Single Command Mode](#single-command-mode) - - [Using Environment Variable for Password](#using-environment-variable-for-password) - - [CLI Flags](#cli-flags) - [Using as a Library](#using-as-a-library) - [Streaming Responses](#streaming-responses) + - [tcprcon-cli](#tcprcon-cli) - [License](#license) -A fully native RCON client implementation, zero third parties* - -*except for other golang maintained packages about terminal emulators, until i fully master tty :( - -![tcprcon demo](.meta/demo.png) - -## Features - -- **Interactive Terminal UI**: full-screen exclusive TUI (like vim or nano) -- **Single Command Mode**: execute a single RCON command and exit -- **Multiple Authentication Methods**: supports password via CLI flag, environment variable (`rcon_password`), or secure prompt -- **Configurable Logging**: syslog-style severity levels for debugging -- **Installable as library**: use the RCON client in your own Go projects, ([see examples](#using-as-a-library)) - -## Installation - -```bash -go install github.com/UltimateForm/tcprcon@latest -``` - -Or build from source: - -note: requires golang 1.22+ - -```bash -git clone https://github.com/UltimateForm/tcprcon.git -cd tcprcon -go build -o tcprcon . -``` - -## Usage - -### Interactive Mode - -```bash -tcprcon --address=192.168.1.100 --port=7778 -``` - -### Single Command Mode - -```bash -tcprcon --address=192.168.1.100 --cmd="playerlist" -``` - -### Using Environment Variable for Password - -```bash -export rcon_password="your_password" -tcprcon --address=192.168.1.100 -``` - -## CLI Flags - -``` - -address string - RCON address, excluding port (default "localhost") - -cmd string - command to execute, if provided will not enter into interactive mode - -log uint - sets log level (syslog severity tiers) for execution (default 4) - -port uint - RCON port (default 7778) - -pw string - RCON password, if not provided will attempt to load from env variables, if unavailable will prompt -``` +A fully native RCON client implementation, zero deps ## Using as a Library @@ -163,7 +93,10 @@ func main() { } ``` +## tcprcon-cli + +https://github.com/UltimateForm/tcprcon-cli + ## License This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See [LICENSE](LICENSE) for details. - diff --git a/cmd/main.go b/cmd/main.go index 54c8471..178fc1d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,7 +2,6 @@ package cmd import ( "bufio" - "context" "errors" "flag" "fmt" @@ -10,126 +9,90 @@ import ( "strconv" "strings" - "github.com/UltimateForm/tcprcon/internal/ansi" - "github.com/UltimateForm/tcprcon/pkg/logger" "github.com/UltimateForm/tcprcon/pkg/common_rcon" + "github.com/UltimateForm/tcprcon/pkg/logger" "github.com/UltimateForm/tcprcon/pkg/packet" "github.com/UltimateForm/tcprcon/pkg/rcon" - "golang.org/x/term" ) var addressParam string var portParam uint var passwordParam string var logLevelParam uint -var inputCmdParam string func init() { flag.StringVar(&addressParam, "address", "localhost", "RCON address, excluding port") flag.UintVar(&portParam, "port", 7778, "RCON port") flag.StringVar(&passwordParam, "pw", "", "RCON password, if not provided will attempt to load from env variables, if unavailable will prompt") flag.UintVar(&logLevelParam, "log", logger.LevelWarning, "sets log level (syslog serverity tiers) for execution") - flag.StringVar(&inputCmdParam, "cmd", "", "command to execute, if provided will not enter into interactive mode") } func determinePassword() (string, error) { if len(passwordParam) > 0 { - logger.Debug.Println("using password from parameter") return passwordParam, nil } envPassword := os.Getenv("rcon_password") var password string if len(envPassword) > 0 { - logger.Debug.Println("using password from os env") r := "" for r == "" { fmt.Print("RCON password found in environment variables, use for authentication? (y/n) ") stdinread := bufio.NewReader(os.Stdin) - stdinbytes, _isPrefix, err := stdinread.ReadLine() + stdinbytes, _, err := stdinread.ReadLine() if err != nil { return "", err } - if _isPrefix { - logger.Err.Println("prefix not supported") - continue - } r = string(stdinbytes) } - if strings.ToLower(r) == "y" { - password = envPassword - } - } - if len(password) == 0 { - fmt.Print("RCON PASSWORD: ") - stdinbytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return "", err + if strings.ToLower(r) != "y" { + return "", errors.New("Unimplemented") } - password = string(stdinbytes) + password = envPassword } return password, nil } -func execInputCmd(rcon *rcon.Client) error { - logger.Debug.Println("executing input command: " + inputCmdParam) - execPacket := packet.New(rcon.Id(), packet.SERVERDATA_EXECCOMMAND, []byte(inputCmdParam)) - fmt.Printf( - "(%v): SND CMD %v\n", - ansi.Format(strconv.Itoa(int(rcon.Id())), ansi.Green, ansi.Bold), - ansi.Format(inputCmdParam, ansi.Blue), - ) - rcon.Write(execPacket.Serialize()) - packetRes, err := packet.Read(rcon) - if err != nil { - return errors.Join(errors.New("error while reading from RCON client"), err) - } - fmt.Printf( - "(%v): RCV PKT %v\n%v\n", - ansi.Format(strconv.Itoa(int(rcon.Id())), ansi.Green, ansi.Bold), - ansi.Format(strconv.Itoa(int(packetRes.Type)), ansi.Green, ansi.Bold), - ansi.Format(strings.TrimRight(packetRes.BodyStr(), "\n\r"), ansi.Green), - ) - return nil -} - func Execute() { flag.Parse() - logLevel := uint8(logLevelParam) - logger.Setup(logLevel) - logger.Debug.Printf("parsed parameters: address=%v, port=%v, pw=%v, log=%v, cmd=%v\n", addressParam, portParam, passwordParam != "", logLevelParam, inputCmdParam) + logger.Setup(uint8(logLevelParam)) fullAddress := addressParam + ":" + strconv.Itoa(int(portParam)) + shell := fmt.Sprintf("[rcon@%v]", fullAddress) password, err := determinePassword() if err != nil { logger.Critical.Fatal(err) } logger.Debug.Printf("Dialing %v at port %v\n", addressParam, portParam) - rconClient, err := rcon.New(fullAddress) + rcon, err := rcon.New(fullAddress) if err != nil { logger.Critical.Fatal(err) } - defer rconClient.Close() + defer rcon.Close() logger.Debug.Println("Building auth packet") - auhSuccess, authErr := common_rcon.Authenticate(rconClient, password) + auhSuccess, authErr := common_rcon.Authenticate(rcon, password) if authErr != nil { - logger.Critical.Println(errors.Join(errors.New("auth failure"), authErr)) - return + logger.Err.Fatal(err) } if !auhSuccess { - logger.Critical.Println(errors.New("unknown auth error")) - return + logger.Err.Fatal(errors.New("auth failure")) } - - if inputCmdParam != "" { - if err := execInputCmd(rconClient); err != nil { - logger.Critical.Println(err) + for { + logger.Info.Println("-----STARTING CMD EXCHANGE-----") + stdinread := bufio.NewReader(os.Stdin) + fmt.Printf("%v#", shell) + cmd, _, err := stdinread.ReadLine() + if err != nil { + logger.Critical.Fatal(err) } - return - } else { - // could just rely on early return but i feel anxious :D - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - runRconTerminal(rconClient, ctx, logLevel) + currId := rcon.Id() + execPacket := packet.New(currId, packet.SERVERDATA_EXECCOMMAND, cmd) + rcon.Write(execPacket.Serialize()) + logger.Debug.Println("Reading from server...") + responsePkt, err := packet.Read(rcon) + if err != nil { + logger.Critical.Fatal(errors.Join(errors.New("error while reading from RCON client"), err)) + } + fmt.Printf("OUT: %v\n", responsePkt.BodyStr()) } + } diff --git a/cmd/terminal.go b/cmd/terminal.go deleted file mode 100644 index 34f7b44..0000000 --- a/cmd/terminal.go +++ /dev/null @@ -1,105 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/UltimateForm/tcprcon/internal/ansi" - "github.com/UltimateForm/tcprcon/internal/fullterm" - "github.com/UltimateForm/tcprcon/pkg/logger" - "github.com/UltimateForm/tcprcon/pkg/packet" - "github.com/UltimateForm/tcprcon/pkg/rcon" -) - -func runRconTerminal(client *rcon.Client, ctx context.Context, logLevel uint8) { - app := fullterm.CreateApp(fmt.Sprintf("rcon@%v", client.Address)) - // dont worry we are resetting the logger before returning - logger.SetupCustomDestination(logLevel, app) - - appErrors := make(chan error, 1) - connectionErrors := make(chan error, 1) - - appRun := func() { - appErrors <- app.Run(ctx) - } - packetChannel := packet.CreateResponseChannel(client, ctx) - packetReader := func() { - for { - select { - case <-ctx.Done(): - return - case streamedPacket := <-packetChannel: - if streamedPacket.Error != nil { - if errors.Is(streamedPacket.Error, os.ErrDeadlineExceeded) { - logger.Debug.Println("read deadline reached; connection is idle or server is silent.") - continue - } - if errors.Is(streamedPacket.Error, io.EOF) { - connectionErrors <- io.EOF - return - } - logger.Err.Println(errors.Join(errors.New("error while reading from RCON client"), streamedPacket.Error)) - continue - } - fmt.Fprintf( - app, - "(%v): RCV PKT %v\n%v\n", - ansi.Format(strconv.Itoa(int(streamedPacket.Id)), ansi.Green, ansi.Bold), - ansi.Format(strconv.Itoa(int(streamedPacket.Type)), ansi.Green, ansi.Bold), - ansi.Format(strings.TrimRight(streamedPacket.BodyStr(), "\n\r"), ansi.Green), - ) - } - } - } - submissionChan := app.Submissions() - submissionReader := func() { - for { - select { - case <-ctx.Done(): - return - case cmd := <-submissionChan: - execPacket := packet.New(client.Id(), packet.SERVERDATA_EXECCOMMAND, []byte(cmd)) - fmt.Fprintf( - app, - "(%v): SND CMD %v\n", - ansi.Format(strconv.Itoa(int(client.Id())), ansi.Green, ansi.Bold), - ansi.Format(cmd, ansi.Blue), - ) - client.Write(execPacket.Serialize()) - } - } - } - go submissionReader() - go packetReader() - go appRun() - - select { - case <-ctx.Done(): - logger.Debug.Println("context done") - break - case err := <-connectionErrors: - defer func() { - logger.Critical.Println(errors.Join(errors.New("connection error"), err)) - }() - break - case err := <-appErrors: - // lets do this because the app might be unrealiable at this point - if err != nil { - defer func() { - logger.Critical.Println(errors.Join(errors.New("app error"), err)) - }() - } else { - defer func() { - logger.Debug.Println("graceful app exit") - }() - } - break - } - app.Close() - logger.Setup(logLevel) -} diff --git a/go.mod b/go.mod index 7521608..007f910 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ module github.com/UltimateForm/tcprcon go 1.25.3 - -require golang.org/x/sys v0.39.0 - -require golang.org/x/term v0.38.0 diff --git a/go.sum b/go.sum index 08d079a..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +0,0 @@ -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= diff --git a/internal/.gitkeep b/internal/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/fullterm/app.go b/internal/fullterm/app.go deleted file mode 100644 index 28f0bb3..0000000 --- a/internal/fullterm/app.go +++ /dev/null @@ -1,159 +0,0 @@ -package fullterm - -import ( - "context" - "errors" - "fmt" - "os" - "os/signal" - "sync" - "syscall" - - "github.com/UltimateForm/tcprcon/internal/ansi" - "golang.org/x/sys/unix" - "golang.org/x/term" -) - -type app struct { - DisplayChannel chan string - submissionChan chan string - stdinChannel chan byte - fd int - prevState *term.State - cmdLine []byte - content []string - commandSignature string - once sync.Once -} - -func (src *app) Write(bytes []byte) (int, error) { - src.DisplayChannel <- string(bytes) - return len(bytes), nil -} - -func (src *app) ListenStdin(context context.Context) { - // we are only listening to the stdin bytes here, to see how we handle conversion to human readable characters go to util.go - for { - select { - case <-context.Done(): - return - default: - b := make([]byte, 1) - _, err := os.Stdin.Read(b) - if err != nil { - return - } - src.stdinChannel <- b[0] - } - } -} -func (src *app) Submissions() <-chan string { - return src.submissionChan -} - -func (src *app) DrawContent(finalDraw bool) error { - _, height, err := term.GetSize(src.fd) - if err != nil { - return err - } - if !finalDraw { - fmt.Print(ansi.ClearScreen + ansi.CursorHome) - } - currentRows := len(src.content) - startRow := max(currentRows-(height+1), 0) - drawableRows := src.content[startRow:] - for i := range drawableRows { - fmt.Print(drawableRows[i]) - } - - if finalDraw { - return nil - } - ansi.MoveCursorTo(height, 0) - fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature) - fmt.Print(string(src.cmdLine)) - return nil -} - -func (src *app) Run(context context.Context) error { - - // this could be an argument but i aint feeling yet - src.fd = int(os.Stdin.Fd()) - if !term.IsTerminal(src.fd) { - return errors.New("expected to run in terminal") - } - - prevState, err := term.MakeRaw(src.fd) - fmt.Print(ansi.EnterAltScreen) - sigch := make(chan os.Signal, 1) - signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGABRT) - - if err != nil { - return err - } - src.prevState = prevState - defer src.Close() - - currFlags, err := unix.IoctlGetTermios(src.fd, unix.TCGETS) - if err != nil { - return err - } - currFlags.Lflag |= unix.ISIG - currFlags.Oflag |= unix.ONLCR | unix.OPOST - // fyi there's a TCSETS as well that applies the setting differently - if err := unix.IoctlSetTermios(src.fd, unix.TCSETSF, currFlags); err != nil { - return err - } - - go src.ListenStdin(context) - for { - select { - case <-sigch: - return nil - case <-context.Done(): - return nil - case newStdinInput := <-src.stdinChannel: - newCmd, isSubmission := constructCmdLine(newStdinInput, src.cmdLine) - if isSubmission { - src.content = append(src.content, ansi.Format("> "+string(newCmd)+"\n", ansi.Blue)) - src.cmdLine = []byte{} - src.submissionChan <- string(newCmd) - } else { - src.cmdLine = newCmd - } - if err := src.DrawContent(false); err != nil { - return err - } - case newDisplayInput := <-src.DisplayChannel: - src.content = append(src.content, newDisplayInput) - if err := src.DrawContent(false); err != nil { - return err - } - } - } -} - -func (src *app) Close() { - src.once.Do(func() { - // note: consider closing channels - fmt.Print(ansi.ExitAltScreen) - src.DrawContent(true) - term.Restore(src.fd, src.prevState) - fmt.Println() - }) -} - -func CreateApp(commandSignature string) *app { - // buffered, so we don't block on input - displayChannel := make(chan string, 10) - displayChannel <- ansi.Format("##########\n", ansi.Yellow, ansi.Bold) - stdinChannel := make(chan byte) - submissionChan := make(chan string, 10) - return &app{ - DisplayChannel: displayChannel, - stdinChannel: stdinChannel, - submissionChan: submissionChan, - content: make([]string, 0), - commandSignature: commandSignature, - } -} diff --git a/internal/fullterm/util.go b/internal/fullterm/util.go deleted file mode 100644 index c00e432..0000000 --- a/internal/fullterm/util.go +++ /dev/null @@ -1,17 +0,0 @@ -package fullterm - -func constructCmdLine(newByte byte, cmdLine []byte) ([]byte, bool) { - isSubmission := false - switch newByte { - case 127, 8: // backspace, delete - if len(cmdLine) > 0 { - cmdLine = cmdLine[:len(cmdLine)-1] - } - case 13, 10: // enter - isSubmission = true - case 27: // escape - default: - cmdLine = append(cmdLine, newByte) - } - return cmdLine, isSubmission -} diff --git a/internal/fullterm/util_test.go b/internal/fullterm/util_test.go deleted file mode 100644 index 57fdb89..0000000 --- a/internal/fullterm/util_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package fullterm - -import "testing" - -func TestConstructCmdLineBasic(t *testing.T) { - currLine := []byte("hell") - newByte := byte('o') - newLine, isSubmission := constructCmdLine(newByte, currLine) - if isSubmission { - t.Error("did not expect submission") - } - if string(newLine) != "hello" { - t.Errorf("expected 'hello' but got '%s'", newLine) - } -} - -func TestConstructCmdLineSubmission(t *testing.T) { - currLine := []byte("hell") - newByte := byte(13) - newLine, isSubmission := constructCmdLine(newByte, currLine) - if !isSubmission { - t.Error("expected submission") - } - if string(newLine) != "hell" { - t.Errorf("expected 'hell' but got '%s'", newLine) - } -} - -func TestConstructCmdLineBackspace(t *testing.T) { - currLine := []byte("hell") - newByte := byte(127) - newLine, isSubmission := constructCmdLine(newByte, currLine) - if isSubmission { - t.Error("did not expect submission") - } - if string(newLine) != "hel" { - t.Errorf("expected 'hel' but got '%s'", newLine) - } -} - -func TestConstructCmdLineBackspaceEmpty(t *testing.T) { - currLine := []byte("") - newByte := byte(127) - newLine, isSubmission := constructCmdLine(newByte, currLine) - if isSubmission { - t.Error("did not expect submission") - } - if string(newLine) != "" { - t.Errorf("expected empty string, got '%s'", newLine) - } -} - -func TestConstructCmdLineBackspaceByte8(t *testing.T) { - currLine := []byte("hi") - newByte := byte(8) - newLine, isSubmission := constructCmdLine(newByte, currLine) - if isSubmission { - t.Error("did not expect submission") - } - if string(newLine) != "h" { - t.Errorf("expected 'h', got '%s'", newLine) - } -} - -func TestConstructCmdLineSubmissionLF(t *testing.T) { - currLine := []byte("test") - newByte := byte(10) - newLine, isSubmission := constructCmdLine(newByte, currLine) - if !isSubmission { - t.Error("expected submission on LF") - } - if string(newLine) != "test" { - t.Errorf("expected 'test', got '%s'", newLine) - } -}