From 9c6dc8d5c9f0ee2c47d77aba712574486855b988 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 18 Jan 2026 23:10:07 +0000 Subject: [PATCH 1/4] feat: implement secure password input using golang.org/x/term --- cmd/main.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index fa2be07..21813a5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -13,6 +13,7 @@ import ( "github.com/UltimateForm/tcprcon/internal/logger" "github.com/UltimateForm/tcprcon/pkg/client" "github.com/UltimateForm/tcprcon/pkg/common" + "golang.org/x/term" ) var addressParam string @@ -55,7 +56,13 @@ func determinePassword() (string, error) { } } if len(password) == 0 { - return "", errors.New("unimplemented password retrieval path") + fmt.Print("RCON PASSWORD: ") + stdinbytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", err + } + password = string(stdinbytes) } return password, nil } From 3e8ad2ba7654ba7ba7a3c995f89ac011c9090307 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 18 Jan 2026 23:48:13 +0000 Subject: [PATCH 2/4] feat: Add CLI option to execute a single RCON command and improve authentication error propagation. --- cmd/main.go | 48 +++++++++++++++++++++++++++++++++++++++++----- pkg/common/auth.go | 2 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 21813a5..84acf74 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,9 +10,11 @@ import ( "strconv" "strings" + "github.com/UltimateForm/tcprcon/internal/ansi" "github.com/UltimateForm/tcprcon/internal/logger" "github.com/UltimateForm/tcprcon/pkg/client" "github.com/UltimateForm/tcprcon/pkg/common" + "github.com/UltimateForm/tcprcon/pkg/packet" "golang.org/x/term" ) @@ -20,12 +22,14 @@ 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) { @@ -67,10 +71,33 @@ func determinePassword() (string, error) { return password, nil } +func execInputCmd(rcon *client.RCONClient) 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) fullAddress := addressParam + ":" + strconv.Itoa(int(portParam)) password, err := determinePassword() if err != nil { @@ -86,12 +113,23 @@ func Execute() { logger.Debug.Println("Building auth packet") auhSuccess, authErr := common.Authenticate(rcon, password) if authErr != nil { - logger.Err.Fatal(errors.Join(errors.New("auth failure"), authErr)) + logger.Critical.Println(errors.Join(errors.New("auth failure"), authErr)) + return } if !auhSuccess { - logger.Err.Fatal(errors.New("unknown auth error")) + logger.Critical.Println(errors.New("unknown auth error")) + return + } + + if inputCmdParam != "" { + if err := execInputCmd(rcon); err != nil { + logger.Critical.Println(err) + } + return + } else { + // could just rely on early return but i feel anxious :D + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runRconTerminal(rcon, ctx, logLevel) } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - runRconTerminal(rcon, ctx, logLevel) } diff --git a/pkg/common/auth.go b/pkg/common/auth.go index 34783b2..56c0e59 100644 --- a/pkg/common/auth.go +++ b/pkg/common/auth.go @@ -13,7 +13,7 @@ func Authenticate(rconClient *client.RCONClient, password string) (bool, error) authPacket := packet.NewAuthPacket(authId, password) written, err := rconClient.Write(authPacket.Serialize()) if err != nil { - logger.Critical.Fatal(err) + return false, err } logger.Debug.Printf("Written %v bytes of auth packet to connection", written) responsePkt, err := packet.ReadWithId(rconClient, authId) From fedc33522e1a8d344c416121854ccad87ecec4e8 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Mon, 19 Jan 2026 00:25:42 +0000 Subject: [PATCH 3/4] refactor: rename `client` package to `rcon`, `common` to `common_rcon`, and update README with library usage examples including streaming responses. --- README.md | 162 +++++++++++++++++++++++++++- cmd/main.go | 16 +-- cmd/terminal.go | 4 +- pkg/{common => common_rcon}/auth.go | 6 +- pkg/packet/stream.go | 4 +- pkg/{client => rcon}/client_test.go | 10 +- pkg/{client => rcon}/rcon.go | 22 ++-- 7 files changed, 192 insertions(+), 32 deletions(-) rename pkg/{common => common_rcon}/auth.go (86%) rename pkg/{client => rcon}/client_test.go (96%) rename pkg/{client => rcon}/rcon.go (55%) diff --git a/README.md b/README.md index 88e60b9..fa31fa7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,168 @@ # 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) + - [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 :( +*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 +``` + +## Using as a Library + +The RCON client can be used as a library in your own Go projects: + +```go +import ( + "github.com/UltimateForm/tcprcon/pkg/rcon" + "github.com/UltimateForm/tcprcon/pkg/common_rcon" + "github.com/UltimateForm/tcprcon/pkg/packet" +) + +func main() { + client, err := rcon.New("192.168.1.100:7778") + if err != nil { + panic(err) + } + defer client.Close() + + // Authenticate + success, err := common_rcon.Authenticate(client, "your_password") + if err != nil || !success { + panic("auth failed") + } + + // Send command + execPacket := packet.New(client.Id(), packet.SERVERDATA_EXECCOMMAND, []byte("playerlist")) + client.Write(execPacket.Serialize()) + + // Read response + response, err := packet.Read(client) + if err != nil { + panic(err) + } + fmt.Println(response.BodyStr()) +} +``` + +### Streaming Responses + +For continuous listening (e.g., server broadcasts or multiple responses), use `CreateResponseChannel`: + +usually you will want a more ellegant way of handling the concurrent nature of this, this example is just for illustration + +```go +import ( + "context" + "fmt" + "io" + + "github.com/UltimateForm/tcprcon/pkg/rcon" + "github.com/UltimateForm/tcprcon/pkg/common_rcon" + "github.com/UltimateForm/tcprcon/pkg/packet" +) + +func main() { + client, _ := rcon.New("192.168.1.100:7778") + defer client.Close() + + common_rcon.Authenticate(client, "your_password") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Create a channel that streams incoming packets + packetChan := packet.CreateResponseChannel(client, ctx) + + // Send a command + execPacket := packet.New(client.Id(), packet.SERVERDATA_EXECCOMMAND, []byte("listen event")) + client.Write(execPacket.Serialize()) + + // Listen for responses + for pkt := range packetChan { + if pkt.Error != nil { + if pkt.Error == io.EOF { + fmt.Println("Connection closed") + break + } + continue // Timeout or other non-fatal error + } + fmt.Printf("Received: %s\n", pkt.BodyStr()) + } +} +``` + +## 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 84acf74..79901f9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,9 +12,9 @@ import ( "github.com/UltimateForm/tcprcon/internal/ansi" "github.com/UltimateForm/tcprcon/internal/logger" - "github.com/UltimateForm/tcprcon/pkg/client" - "github.com/UltimateForm/tcprcon/pkg/common" + "github.com/UltimateForm/tcprcon/pkg/common_rcon" "github.com/UltimateForm/tcprcon/pkg/packet" + "github.com/UltimateForm/tcprcon/pkg/rcon" "golang.org/x/term" ) @@ -71,7 +71,7 @@ func determinePassword() (string, error) { return password, nil } -func execInputCmd(rcon *client.RCONClient) error { +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( @@ -104,14 +104,14 @@ func Execute() { logger.Critical.Fatal(err) } logger.Debug.Printf("Dialing %v at port %v\n", addressParam, portParam) - rcon, err := client.New(fullAddress) + rconClient, err := rcon.New(fullAddress) if err != nil { logger.Critical.Fatal(err) } - defer rcon.Close() + defer rconClient.Close() logger.Debug.Println("Building auth packet") - auhSuccess, authErr := common.Authenticate(rcon, password) + auhSuccess, authErr := common_rcon.Authenticate(rconClient, password) if authErr != nil { logger.Critical.Println(errors.Join(errors.New("auth failure"), authErr)) return @@ -122,7 +122,7 @@ func Execute() { } if inputCmdParam != "" { - if err := execInputCmd(rcon); err != nil { + if err := execInputCmd(rconClient); err != nil { logger.Critical.Println(err) } return @@ -130,6 +130,6 @@ func Execute() { // could just rely on early return but i feel anxious :D ctx, cancel := context.WithCancel(context.Background()) defer cancel() - runRconTerminal(rcon, ctx, logLevel) + runRconTerminal(rconClient, ctx, logLevel) } } diff --git a/cmd/terminal.go b/cmd/terminal.go index 2dbf4b8..d3767a6 100644 --- a/cmd/terminal.go +++ b/cmd/terminal.go @@ -12,11 +12,11 @@ import ( "github.com/UltimateForm/tcprcon/internal/ansi" "github.com/UltimateForm/tcprcon/internal/fullterm" "github.com/UltimateForm/tcprcon/internal/logger" - "github.com/UltimateForm/tcprcon/pkg/client" "github.com/UltimateForm/tcprcon/pkg/packet" + "github.com/UltimateForm/tcprcon/pkg/rcon" ) -func runRconTerminal(client *client.RCONClient, ctx context.Context, logLevel uint8) { +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) diff --git a/pkg/common/auth.go b/pkg/common_rcon/auth.go similarity index 86% rename from pkg/common/auth.go rename to pkg/common_rcon/auth.go index 56c0e59..5a9093e 100644 --- a/pkg/common/auth.go +++ b/pkg/common_rcon/auth.go @@ -1,14 +1,14 @@ -package common +package common_rcon import ( "fmt" "github.com/UltimateForm/tcprcon/internal/logger" - "github.com/UltimateForm/tcprcon/pkg/client" "github.com/UltimateForm/tcprcon/pkg/packet" + "github.com/UltimateForm/tcprcon/pkg/rcon" ) -func Authenticate(rconClient *client.RCONClient, password string) (bool, error) { +func Authenticate(rconClient *rcon.Client, password string) (bool, error) { authId := rconClient.Id() authPacket := packet.NewAuthPacket(authId, password) written, err := rconClient.Write(authPacket.Serialize()) diff --git a/pkg/packet/stream.go b/pkg/packet/stream.go index 3c0b779..5f2a416 100644 --- a/pkg/packet/stream.go +++ b/pkg/packet/stream.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/UltimateForm/tcprcon/pkg/client" + "github.com/UltimateForm/tcprcon/pkg/rcon" ) type StreamedPacket struct { @@ -12,7 +12,7 @@ type StreamedPacket struct { RCONPacket } -func CreateResponseChannel(con *client.RCONClient, ctx context.Context) <-chan StreamedPacket { +func CreateResponseChannel(con *rcon.Client, ctx context.Context) <-chan StreamedPacket { packetChan := make(chan StreamedPacket) stream := func() { defer close(packetChan) diff --git a/pkg/client/client_test.go b/pkg/rcon/client_test.go similarity index 96% rename from pkg/client/client_test.go rename to pkg/rcon/client_test.go index dea97f8..fd84b6f 100644 --- a/pkg/client/client_test.go +++ b/pkg/rcon/client_test.go @@ -1,4 +1,4 @@ -package client +package rcon import ( "bytes" @@ -56,7 +56,7 @@ func (m *MockConn) SetWriteDeadline(t time.Time) error { func TestRCONClientId(t *testing.T) { mock := &MockConn{} - client := &RCONClient{ + client := &Client{ Address: "test:27015", con: mock, count: 42, @@ -70,7 +70,7 @@ func TestRCONClientId(t *testing.T) { func TestRCONClientWrite(t *testing.T) { mock := &MockConn{} - client := &RCONClient{ + client := &Client{ Address: "test:27015", con: mock, count: 0, @@ -99,7 +99,7 @@ func TestRCONClientWrite(t *testing.T) { func TestRCONClientWriteIncrementsCount(t *testing.T) { mock := &MockConn{} - client := &RCONClient{ + client := &Client{ Address: "test:27015", con: mock, count: 0, @@ -118,7 +118,7 @@ func TestRCONClientRead(t *testing.T) { testData := []byte("response data") mock := &MockConn{readData: testData} - client := &RCONClient{ + client := &Client{ Address: "test:27015", con: mock, count: 0, diff --git a/pkg/client/rcon.go b/pkg/rcon/rcon.go similarity index 55% rename from pkg/client/rcon.go rename to pkg/rcon/rcon.go index 47c1ae5..8db426f 100644 --- a/pkg/client/rcon.go +++ b/pkg/rcon/rcon.go @@ -1,4 +1,4 @@ -package client +package rcon import ( "net" @@ -7,50 +7,50 @@ import ( "github.com/UltimateForm/tcprcon/internal/logger" ) -type RCONClient struct { +type Client struct { Address string con net.Conn count int32 } -func (src *RCONClient) Id() int32 { +func (src *Client) Id() int32 { return src.count } -func (src *RCONClient) Read(p []byte) (int, error) { +func (src *Client) Read(p []byte) (int, error) { return src.con.Read(p) } -func (src *RCONClient) Write(p []byte) (int, error) { +func (src *Client) Write(p []byte) (int, error) { defer func() { src.count++ }() return src.con.Write(p) } -func (src *RCONClient) SetReadDeadline(t time.Time) error { +func (src *Client) SetReadDeadline(t time.Time) error { return src.con.SetReadDeadline(t) } -func (src *RCONClient) SetDeadline(t time.Time) error { +func (src *Client) SetDeadline(t time.Time) error { return src.con.SetDeadline(t) } -func (src *RCONClient) SetWriteDeadline(t time.Time) error { +func (src *Client) SetWriteDeadline(t time.Time) error { return src.con.SetWriteDeadline(t) } -func (src *RCONClient) Close() error { +func (src *Client) Close() error { logger.Debug.Println("Closing connection to", src.Address) return src.con.Close() } -func New(address string) (*RCONClient, error) { +func New(address string) (*Client, error) { con, err := net.Dial("tcp", address) if err != nil { return nil, err } - return &RCONClient{ + return &Client{ Address: address, con: con, count: 0, From bf9ef36b00c9915bb2e9c35dcec629bca628cb7e Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Mon, 19 Jan 2026 00:31:32 +0000 Subject: [PATCH 4/4] feat: add TCPRCON prefix to all global log messages --- internal/logger/logwriter.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/logger/logwriter.go b/internal/logger/logwriter.go index 2773ed8..e168930 100644 --- a/internal/logger/logwriter.go +++ b/internal/logger/logwriter.go @@ -102,11 +102,11 @@ var ( ) func setGlobalLoggers() { - Info = log.New(writer.Info, ansi.Format("INF::", ansi.DefaultColor), 0) - Debug = log.New(writer.Debug, ansi.Format("DBG::", ansi.Yellow), 0) - Err = log.New(writer.Error, ansi.Format("ERR::", ansi.Red), 0) - Warn = log.New(writer.Warn, ansi.Format("WRN::", ansi.Magenta), 0) - Critical = log.New(writer.Critical, ansi.Format("CRT::", ansi.Red), 0) + Info = log.New(writer.Info, ansi.Format("TCPRCON:INF::", ansi.DefaultColor), 0) + Debug = log.New(writer.Debug, ansi.Format("TCPRCON:DBG::", ansi.Yellow), 0) + Err = log.New(writer.Error, ansi.Format("TCPRCON:ERR::", ansi.Red), 0) + Warn = log.New(writer.Warn, ansi.Format("TCPRCON:WRN::", ansi.Magenta), 0) + Critical = log.New(writer.Critical, ansi.Format("TCPRCON:CRT::", ansi.Red), 0) } func SetupCustomDestination(level uint8, customWriter io.Writer) {