From e9d3c8fabb1c4e19d951f7c3448ec01f6372dc60 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sun, 22 Mar 2026 19:08:46 +0000 Subject: [PATCH] feat: keepalive --- README.md | 28 ++++++++++++- cmd/main.go | 40 ++++++++++++++----- cmd/terminal.go | 30 +++++++++++++- internal/config/config.go | 48 ++++++++-------------- internal/config/config_test.go | 73 +++++++++++++++++++--------------- internal/config/models.go | 24 +++++++++++ 6 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 internal/config/models.go diff --git a/README.md b/README.md index 94d0d7d..815e636 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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)) @@ -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 @@ -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 diff --git a/cmd/main.go b/cmd/main.go index 80b197e..f56b7b8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/UltimateForm/tcprcon-cli/internal/ansi" "github.com/UltimateForm/tcprcon-cli/internal/config" @@ -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") @@ -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) { @@ -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) } @@ -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) @@ -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) @@ -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, + ) } } diff --git a/cmd/terminal.go b/cmd/terminal.go index bf9d997..74a2e3d 100644 --- a/cmd/terminal.go +++ b/cmd/terminal.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/UltimateForm/tcprcon-cli/internal/ansi" "github.com/UltimateForm/tcprcon-cli/internal/fullterm" @@ -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) @@ -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(): diff --git a/internal/config/config.go b/internal/config/config.go index 6127bdf..3c24509 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eae3ca1..2659877 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -181,26 +181,28 @@ func TestResolveShouldLoadFromConfig(t *testing.T) { }, } sourceConfig.Save(baseConfigPath) - resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + resolvedProfile, err := Resolve( baseConfigPath, "pleyades", - DefaultAddr, - DefaultPort, - "", + Profile{ + Address: DefaultAddr, + Port: DefaultPort, + Password: "", + }, ) if err != nil { t.Fatal(errors.Join(errors.New("unexpected load error"), err)) } - if resolvedAddress != sourceProfile.Address { - t.Errorf("expected address %s, but got %s", sourceProfile.Address, resolvedAddress) + if resolvedProfile.Address != sourceProfile.Address { + t.Errorf("expected address %s, but got %s", sourceProfile.Address, resolvedProfile.Address) } - if resolvedPort != sourceProfile.Port { - t.Errorf("expected port %d, but got %d", sourceProfile.Port, resolvedPort) + if resolvedProfile.Port != sourceProfile.Port { + t.Errorf("expected port %d, but got %d", sourceProfile.Port, resolvedProfile.Port) } - if resolvedPassword != sourceProfile.Password { - t.Errorf("expected password %s, but got %s", sourceProfile.Password, resolvedPassword) + if resolvedProfile.Password != sourceProfile.Password { + t.Errorf("expected password %s, but got %s", sourceProfile.Password, resolvedProfile.Password) } } @@ -222,24 +224,26 @@ func TestResolveShouldNotOverrideExplicitFlags(t *testing.T) { explicitPort := uint(9999) explicitPw := "explicitpassword" - resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + resolvedProfile, err := Resolve( baseConfigPath, "pleyades", - explicitAddr, - explicitPort, - explicitPw, + Profile{ + Address: explicitAddr, + Port: explicitPort, + Password: explicitPw, + }, ) if err != nil { t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) } - if resolvedAddress != explicitAddr { - t.Errorf("expected explicit address %s, but got %s", explicitAddr, resolvedAddress) + if resolvedProfile.Address != explicitAddr { + t.Errorf("expected explicit address %s, but got %s", explicitAddr, resolvedProfile.Address) } - if resolvedPort != explicitPort { - t.Errorf("expected explicit port %d, but got %d", explicitPort, resolvedPort) + if resolvedProfile.Port != explicitPort { + t.Errorf("expected explicit port %d, but got %d", explicitPort, resolvedProfile.Port) } - if resolvedPassword != explicitPw { - t.Errorf("expected explicit password %s, but got %s", explicitPw, resolvedPassword) + if resolvedProfile.Password != explicitPw { + t.Errorf("expected explicit password %s, but got %s", explicitPw, resolvedProfile.Password) } } @@ -250,7 +254,12 @@ func TestResolveShouldReturnErrorForMissingProfile(t *testing.T) { } emptyConfig.Save(baseConfigPath) - _, _, _, err := Resolve(baseConfigPath, "nonexistent", DefaultAddr, DefaultPort, "") + _, err := Resolve(baseConfigPath, "nonexistent", Profile{ + Address: DefaultAddr, + Port: DefaultPort, + Password: "", + }, + ) if err == nil { t.Fatal("expected error for missing profile, got nil") } @@ -259,24 +268,26 @@ func TestResolveShouldReturnErrorForMissingProfile(t *testing.T) { func TestResolveShouldReturnDefaultsWhenNoProfile(t *testing.T) { baseConfigPath := t.TempDir() - resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + resolveProfile, err := Resolve( baseConfigPath, "", - DefaultAddr, - DefaultPort, - "", + Profile{ + Address: DefaultAddr, + Port: DefaultPort, + Password: "", + }, ) if err != nil { t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) } - if resolvedAddress != DefaultAddr { - t.Errorf("expected default address %s, but got %s", DefaultAddr, resolvedAddress) + if resolveProfile.Address != DefaultAddr { + t.Errorf("expected default address %s, but got %s", DefaultAddr, resolveProfile.Address) } - if resolvedPort != DefaultPort { - t.Errorf("expected default port %d, but got %d", DefaultPort, resolvedPort) + if resolveProfile.Port != DefaultPort { + t.Errorf("expected default port %d, but got %d", DefaultPort, resolveProfile.Port) } - if resolvedPassword != "" { - t.Errorf("expected empty password, but got %s", resolvedPassword) + if resolveProfile.Password != "" { + t.Errorf("expected empty password, but got %s", resolveProfile.Password) } } diff --git a/internal/config/models.go b/internal/config/models.go new file mode 100644 index 0000000..1a95e8f --- /dev/null +++ b/internal/config/models.go @@ -0,0 +1,24 @@ +package config + +import "time" + +type Profile struct { + Address string `json:"address"` + Port uint `json:"port"` + Password string `json:"password,omitempty"` // omitempty so we don't save empty strings + Pulse string `json:"pulse,omitempty"` + PulseInterval time.Duration `json:"pulseInterval"` // i do like using time.Duration, but beware that this means it will be serialized in nanoseconds in the json file +} + +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 + DefaultPulseInterval = time.Second * 60 +)