Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 161 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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*

<sub>*except for other golang maintained packages about terminal emulators, until i fully master tty :(</sub>
<sub>*except for other golang maintained packages about terminal emulators, until i fully master tty :(</sub>

![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:
<sub>note: requires golang 1.22+</sub>

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

<sub>usually you will want a more ellegant way of handling the concurrent nature of this, this example is just for illustration</sub>

```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.

67 changes: 56 additions & 11 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions cmd/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions internal/logger/logwriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions pkg/common/auth.go → pkg/common_rcon/auth.go
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions pkg/packet/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import (
"context"
"time"

"github.com/UltimateForm/tcprcon/pkg/client"
"github.com/UltimateForm/tcprcon/pkg/rcon"
)

type StreamedPacket struct {
Error error
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)
Expand Down
10 changes: 5 additions & 5 deletions pkg/client/client_test.go → pkg/rcon/client_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package client
package rcon

import (
"bytes"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading