diff --git a/companion-agent/cmd/agent/main.go b/companion-agent/cmd/agent/main.go index 5892bb8..1601207 100644 --- a/companion-agent/cmd/agent/main.go +++ b/companion-agent/cmd/agent/main.go @@ -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 diff --git a/companion-agent/go.mod b/companion-agent/go.mod index a83c286..c026b33 100644 --- a/companion-agent/go.mod +++ b/companion-agent/go.mod @@ -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 diff --git a/companion-agent/go.sum b/companion-agent/go.sum index 59abedc..0e10759 100644 --- a/companion-agent/go.sum +++ b/companion-agent/go.sum @@ -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= diff --git a/companion-agent/internal/app/daemon.go b/companion-agent/internal/app/daemon.go index 7a14c51..c98a355 100644 --- a/companion-agent/internal/app/daemon.go +++ b/companion-agent/internal/app/daemon.go @@ -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) } diff --git a/companion-agent/internal/rcon/client.go b/companion-agent/internal/rcon/client.go new file mode 100644 index 0000000..71054d7 --- /dev/null +++ b/companion-agent/internal/rcon/client.go @@ -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 + } + } +}