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 :(

+## 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 fa2be07..79901f9 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -10,21 +10,26 @@ 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/common_rcon"
+ "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) {
@@ -55,36 +60,76 @@ 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
}
+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)
fullAddress := addressParam + ":" + strconv.Itoa(int(portParam))
password, err := determinePassword()
if err != nil {
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.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(rconClient); 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(rconClient, ctx, logLevel)
}
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- runRconTerminal(rcon, 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/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) {
diff --git a/pkg/common/auth.go b/pkg/common_rcon/auth.go
similarity index 84%
rename from pkg/common/auth.go
rename to pkg/common_rcon/auth.go
index 34783b2..5a9093e 100644
--- a/pkg/common/auth.go
+++ b/pkg/common_rcon/auth.go
@@ -1,19 +1,19 @@
-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())
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)
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,