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 :(
-
-
-
-## 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)
- }
-}