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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- [Usage](#usage)
- [Interactive Mode](#interactive-mode)
- [Single Command Mode](#single-command-mode)
- [Keepalive (Pulse)](#keepalive-pulse)
- [Using Environment Variable for Password](#using-environment-variable-for-password)
- [Configuration Profiles](#configuration-profiles)
- [CLI Flags](#cli-flags)
Expand Down Expand Up @@ -69,9 +70,10 @@ You can use the provided `Makefile` and `compose.yaml` to spin up a local develo

## Features

- **Interactive Terminal UI**: full-screen exclusive TUI (like vim or nano)
- **Interactive Terminal UI**: full-screen exclusive TUI (like vim or nano) with command history and scrollable output
- **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
- **Keepalive (Pulse)**: configurable periodic command to keep the connection alive on idle servers
- **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))

Expand Down Expand Up @@ -105,6 +107,26 @@ tcprcon-cli --address=192.168.1.100 --port=7778
tcprcon-cli --address=192.168.1.100 --cmd="playerlist"
```

### Keepalive (Pulse)

To keep the connection alive on idle servers, use `-pulse` with a command your server accepts as a no-op:

```bash
tcprcon-cli --address=192.168.1.100 --pulse="alive"
```

The default interval is 60 seconds. Override it with `-pulse-interval`:

```bash
tcprcon-cli --address=192.168.1.100 --pulse="alive" --pulse-interval=30s
```

Pulse settings can also be saved to a profile:

```bash
tcprcon-cli --address=192.168.1.100 --pulse="alive" --pulse-interval=30s --save="my_server"
```

### Using Environment Variable for Password

```bash
Expand Down Expand Up @@ -154,6 +176,10 @@ tcprcon-cli --profile="my_server" --port=27015
RCON port (default 7778)
-profile string
loads a saved profile by name, overriding default flags but overridden by explicit flags.
-pulse string
keepalive method: a command sent on a schedule to keep the connection alive
-pulse-interval duration
keepalive interval, use Go duration format e.g. 30s, 2m (default 1m0s)
-pw string
RCON password, if not provided will attempt to load from env variables, if unavailable will prompt
-save string
Expand Down
40 changes: 29 additions & 11 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"strconv"
"strings"
"time"

"github.com/UltimateForm/tcprcon-cli/internal/ansi"
"github.com/UltimateForm/tcprcon-cli/internal/config"
Expand All @@ -26,6 +27,8 @@ var logLevelParam uint
var inputCmdParam string
var saveParam string
var profileParam string
var pulseParam string
var pulseIntervalParam time.Duration

func init() {
flag.StringVar(&addressParam, "address", config.DefaultAddr, "RCON address, excluding port")
Expand All @@ -35,6 +38,8 @@ func init() {
flag.StringVar(&inputCmdParam, "cmd", "", "command to execute, if provided will not enter into interactive mode")
flag.StringVar(&saveParam, "save", "", "saves current connection parameters as a profile, value is the profile name")
flag.StringVar(&profileParam, "profile", "", "loads a saved profile by name, overriding default flags but overridden by explicit flags")
flag.StringVar(&pulseParam, "pulse", "", "the keepalive method, a command to be invoked on schedule (pulse-interval param) in order to keep connection alive")
flag.DurationVar(&pulseIntervalParam, "pulse-interval", config.DefaultPulseInterval, "the keepalive method interval, use format 2s/1m")
}

func determinePassword(currentPw string) (string, error) {
Expand Down Expand Up @@ -109,21 +114,25 @@ func Execute() {
if err != nil {
logger.Critical.Fatal(err)
}
resolvedAddress, resolvedPort, resolvedPassword, err := config.Resolve(
resolvedProfile, err := config.Resolve(
configBasePath,
profileParam,
addressParam,
portParam,
passwordParam,
config.Profile{
Address: addressParam,
Port: portParam,
Password: passwordParam,
Pulse: pulseParam,
PulseInterval: pulseIntervalParam,
},
)
if err != nil {
logger.Critical.Fatal(err)
}

logger.Debug.Printf("resolved parameters: address=%v, port=%v, pw=%v, log=%v, cmd=%v\n", resolvedAddress, resolvedPort, resolvedPassword != "", logLevelParam, inputCmdParam)
logger.Debug.Printf("resolved parameters: address=%v, port=%v, pw=%v, log=%v, cmd=%v, pulse=%v, pulseInterval=%v\n", resolvedProfile.Address, resolvedProfile.Port, resolvedProfile.Password != "", logLevelParam, inputCmdParam, resolvedProfile.Pulse, resolvedProfile.PulseInterval)

fullAddress := resolvedAddress + ":" + strconv.Itoa(int(resolvedPort))
password, err := determinePassword(resolvedPassword)
fullAddress := resolvedProfile.Address + ":" + strconv.Itoa(int(resolvedProfile.Port))
password, err := determinePassword(resolvedProfile.Password)
if err != nil {
logger.Critical.Fatal(err)
}
Expand All @@ -136,8 +145,10 @@ func Execute() {
}

newProfile := config.Profile{
Address: resolvedAddress,
Port: resolvedPort,
Address: resolvedProfile.Address,
Port: resolvedProfile.Port,
Pulse: resolvedProfile.Pulse,
PulseInterval: resolvedProfile.PulseInterval,
}

reader := bufio.NewReader(os.Stdin)
Expand All @@ -162,7 +173,7 @@ func Execute() {
// TODO: consider exiting here if user calls cli with "save" param, maybe he just setting profile and dont want to run the full thing idk
}

logger.Debug.Printf("Dialing %v at port %v\n", resolvedAddress, resolvedPort)
logger.Debug.Printf("Dialing %v at port %v\n", resolvedProfile.Address, resolvedProfile.Port)
rconClient, err := rcon.New(fullAddress)
if err != nil {
logger.Critical.Fatal(err)
Expand All @@ -189,6 +200,13 @@ func Execute() {
// could just rely on early return but i feel anxious :D
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runRconTerminal(rconClient, ctx, logLevel)
runRconTerminal(
ctx,
rconClient,
logLevel,
profileParam,
resolvedProfile.Pulse,
resolvedProfile.PulseInterval,
)
}
}
30 changes: 28 additions & 2 deletions cmd/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"time"

"github.com/UltimateForm/tcprcon-cli/internal/ansi"
"github.com/UltimateForm/tcprcon-cli/internal/fullterm"
Expand All @@ -16,8 +17,29 @@ import (
"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))
func stayAlive(ctx context.Context, client *rcon.Client, command string, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
id := client.Id()
logger.Debug.Printf("sending keepalive packet with id %v", id)
pulsePacket := packet.New(client.Id(), packet.SERVERDATA_EXECCOMMAND, []byte(command))
// note: potential race condition here, it is possible we send packet at the exact same time the user does, just fyi
client.Write(pulsePacket.Serialize())
case <-ctx.Done():
return
}
}
}

func runRconTerminal(ctx context.Context, client *rcon.Client, logLevel uint8, profileName string, pulseCmd string, pulseInterval time.Duration) {
signatureProfile := "rcon"
if profileName != "" {
signatureProfile = profileName
}
app := fullterm.CreateApp(fmt.Sprintf("%v@%v", signatureProfile, client.Address))
// dont worry we are resetting the logger before returning
logger.SetupCustomDestination(logLevel, app)

Expand Down Expand Up @@ -74,9 +96,13 @@ func runRconTerminal(client *rcon.Client, ctx context.Context, logLevel uint8) {
}
}
}

go submissionReader()
go packetReader()
go appRun()
if pulseCmd != "" {
go stayAlive(ctx, client, pulseCmd, pulseInterval)
}

select {
case <-ctx.Done():
Expand Down
48 changes: 16 additions & 32 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,6 @@ import (
"path/filepath"
)

type Profile struct {
Address string `json:"address"`
Port uint `json:"port"`
Password string `json:"password,omitempty"` // omitempty so we don't save empty strings
}

type Config struct {
Profiles map[string]Profile `json:"profiles"`
// profiles could be root but preparing for potential expansion, jic
}

const (
configDirName = "tcprcon"
configFileName = "config.json"
DefaultAddr = "localhost"
DefaultPort = 7778
)

func BuildConfigPath(basePath string) (string, error) {
if basePath == "" {
return "", ErrUndefinedConfigBasePath
Expand Down Expand Up @@ -99,35 +81,37 @@ func (source *Config) SetProfile(name string, p Profile) {
source.Profiles[name] = p
}

func Resolve(configBasePath string, profileName string, addrFlag string, portFlag uint, pwFlag string) (string, uint, string, error) {
func Resolve(configBasePath string, profileName string, prof Profile) (Profile, error) {
cfg, err := Load(configBasePath)
if err != nil {
return "", 0, "", err
return Profile{}, err
}

finalAddr := addrFlag
finalPort := portFlag
finalPw := pwFlag

if profileName != "" {
p, ok := cfg.GetProfile(profileName)
if !ok {
return "", 0, "", fmt.Errorf("profile '%s' not found", profileName)
return Profile{}, fmt.Errorf("profile '%s' not found", profileName)
}

// only override if the flags are still at their default values
// NOTE: this logic assumes defaults are "localhost", 7778, and ""
// TODO: this "default" handling can introduce bugs, rethink this at some point
if finalAddr == DefaultAddr && p.Address != "" {
finalAddr = p.Address
if prof.Address == DefaultAddr && p.Address != "" {
prof.Address = p.Address
}
if prof.Port == DefaultPort && p.Port != 0 {
prof.Port = p.Port
}
if prof.Password == "" && p.Password != "" {
prof.Password = p.Password
}
if finalPort == DefaultPort && p.Port != 0 {
finalPort = p.Port
if prof.Pulse == "" && p.Pulse != "" {
prof.Pulse = p.Pulse
}
if finalPw == "" && p.Password != "" {
finalPw = p.Password
if prof.PulseInterval == DefaultPulseInterval && p.PulseInterval != 0 {
prof.PulseInterval = p.PulseInterval
}
}

return finalAddr, finalPort, finalPw, nil
return prof, nil
}
Loading
Loading