feat: Add WebSocket RCON client to companion agent
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

Wire gorilla/websocket into the Go companion agent to send arbitrary
console commands (e.g. oxide.reload BetterLoot) to the Rust Dedicated
Server's WebRCON endpoint. Adds RCON_PORT and RCON_PASSWORD env vars,
a new "command" action on the existing cmd.server NATS subject, and
the internal/rcon package that handles the JSON-over-WebSocket protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-22 00:16:47 -05:00
parent f67b175d39
commit eb57c51a24
5 changed files with 113 additions and 1 deletions

View File

@@ -31,6 +31,10 @@ type Config struct {
// Install directory for deployment
InstallDir string `envconfig:"INSTALL_DIR" default:""`
// RCON configuration
RconPort int `envconfig:"RCON_PORT" default:"28016"`
RconPassword string `envconfig:"RCON_PASSWORD" default:""`
// Optional settings
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
@@ -63,6 +67,7 @@ func main() {
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
log.Printf(" Install Dir: %s", cfg.InstallDir)
log.Printf(" RCON Port: %d", cfg.RconPort)
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
// Create context with signal handling for graceful shutdown
@@ -88,6 +93,8 @@ func main() {
GameServerArgs: cfg.GameServerArgs,
Version: version,
InstallDir: cfg.InstallDir,
RconPort: cfg.RconPort,
RconPassword: cfg.RconPassword,
}
// Start daemon

View File

@@ -8,6 +8,7 @@ require (
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/nats-io/nkeys v0.4.5 // indirect
github.com/nats-io/nuid v1.0.1 // indirect

View File

@@ -1,3 +1,5 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=

View File

@@ -13,6 +13,7 @@ import (
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/rcon"
"github.com/vigilcyber/corrosion-companion/internal/update"
)
@@ -25,6 +26,8 @@ type DaemonConfig struct {
GameServerArgs string
Version string
InstallDir string
RconPort int
RconPassword string
}
// Daemon manages the companion agent's main operations
@@ -155,7 +158,8 @@ func (d *Daemon) subscribeServerCommands() error {
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
var cmd struct {
Action string `json:"action"`
Action string `json:"action"`
Command string `json:"command"`
}
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
@@ -174,6 +178,24 @@ func (d *Daemon) subscribeServerCommands() error {
err = d.gameServer.Stop()
case "restart":
err = d.gameServer.Restart()
case "command":
if cmd.Command == "" {
d.respondError(msg, "invalid_command", "command field is required")
return
}
result, rconErr := rcon.SendCommand(d.cfg.RconPort, d.cfg.RconPassword, cmd.Command)
if rconErr != nil {
log.Printf("RCON command failed: %v", rconErr)
d.respondError(msg, "rcon_failed", rconErr.Error())
} else {
d.respondSuccess(msg, map[string]interface{}{
"action": "command",
"command": cmd.Command,
"response": result,
"status": "success",
})
}
return
default:
err = fmt.Errorf("unknown action: %s", cmd.Action)
}

View File

@@ -0,0 +1,80 @@
package rcon
import (
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/gorilla/websocket"
)
// RconRequest is the JSON payload sent to Rust's WebRCON.
type RconRequest struct {
Identifier int `json:"Identifier"`
Message string `json:"Message"`
Name string `json:"Name"`
}
// RconResponse is the JSON payload received from Rust's WebRCON.
type RconResponse struct {
Identifier int `json:"Identifier"`
Message string `json:"Message"`
Type string `json:"Type"`
}
// SendCommand opens a WebSocket to the Rust server's RCON port, sends
// a single command, reads the response, and closes the connection.
func SendCommand(port int, password string, command string) (string, error) {
u := url.URL{
Scheme: "ws",
Host: fmt.Sprintf("127.0.0.1:%d", port),
Path: fmt.Sprintf("/%s", password),
}
dialer := websocket.Dialer{
HandshakeTimeout: 5 * time.Second,
}
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return "", fmt.Errorf("rcon dial failed: %w", err)
}
defer conn.Close()
// Set read deadline
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
req := RconRequest{
Identifier: 1,
Message: command,
Name: "Corrosion",
}
data, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("rcon marshal failed: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return "", fmt.Errorf("rcon write failed: %w", err)
}
// Read response — may get multiple messages (Generic, Warning, etc.)
// We want the first response with our Identifier.
for {
_, message, err := conn.ReadMessage()
if err != nil {
return "", fmt.Errorf("rcon read failed: %w", err)
}
var resp RconResponse
if err := json.Unmarshal(message, &resp); err != nil {
continue // skip unparseable messages
}
if resp.Identifier == req.Identifier {
return resp.Message, nil
}
}
}