package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"gopkg.in/yaml.v3"
)
// DefaultConfigName sets the default config file name.
const DefaultConfigName = "rcon.yaml"
// DefaultConfigEnv is the name of the environment, which is taken
// as default unless another value is passed.
const DefaultConfigEnv = "default"
var (
// ErrConfigValidation is when config validation completed with errors.
ErrConfigValidation = errors.New("config validation error")
// ErrUnsupportedFileExt is returned when config file has an unsupported
// extension. Allowed extensions is `.json`, `.yml`, `.yaml`.
ErrUnsupportedFileExt = errors.New("unsupported file extension")
)
// Config allows to take a remote server address and password from
// the configuration file. This enables not to specify these flags when
// running the CLI.
//
// Example:
// ```yaml
// default:
//
// address: "127.0.0.1:16260"
// password: "password"
//
// ```.
type Config map[string]Session
// NewConfig finds and parses config file with remote server credentials.
func NewConfig(name string) (*Config, error) {
cfg := new(Config)
if err := cfg.ParseFromFile(name); err != nil {
return nil, fmt.Errorf("parse file: %w", err)
}
if err := cfg.Validate(); err != nil {
return cfg, err
}
return cfg, nil
}
// ParseFromFile reads a configuration file from disk and loads its contents into
// the application's config structure. YAML and JSON files are supported.
func (cfg *Config) ParseFromFile(name string) error {
if name != "" {
return cfg.parse(name)
}
home, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
return fmt.Errorf("get abs path: %w", err)
}
name = home + "/" + DefaultConfigName
if err = cfg.parse(name); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
*cfg = Config{DefaultConfigEnv: {}}
return nil
}
// Validate validates the config fields.
func (cfg *Config) Validate() error {
if cfg == nil {
return fmt.Errorf("%w: config is not set", ErrConfigValidation)
}
for key, ses := range *cfg {
switch ses.Type {
case "", ProtocolRCON, ProtocolTELNET, ProtocolWebRCON:
default:
return fmt.Errorf("%w: unsupported type in %s environment", ErrConfigValidation, key)
}
}
return nil
}
func (cfg *Config) parse(name string) error {
file, err := os.ReadFile(name)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
switch ext := path.Ext(name); ext {
case ".yml", ".yaml":
err = yaml.Unmarshal(file, cfg)
case ".json":
err = json.Unmarshal(file, cfg)
default:
err = fmt.Errorf("%w %s", ErrUnsupportedFileExt, ext)
}
return err
}
package config
import (
"encoding/json"
"fmt"
"io"
"time"
)
// Allowed protocols.
const (
ProtocolRCON = "rcon"
ProtocolTELNET = "telnet"
ProtocolWebRCON = "web"
)
// DefaultProtocol contains the default protocol for connecting to a
// remote server.
const DefaultProtocol = ProtocolRCON
// DefaultTimeout contains the default dial and execute timeout.
const DefaultTimeout = 10 * time.Second
// Session contains details for making a request on a remote server.
type Session struct {
Address string `json:"address" yaml:"address"`
Password string `json:"password" yaml:"password"`
// Log is the name of the file to which requests will be logged.
// If not specified, no logging will be performed.
Log string `json:"log" yaml:"log"`
Type string `json:"type" yaml:"type"`
SkipErrors bool `json:"skip_errors" yaml:"skip_errors"`
Timeout time.Duration `json:"timeout" yaml:"timeout"`
Variables bool `json:"-" yaml:"-"`
}
func (s *Session) Print(w io.Writer) error {
js, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
_, _ = fmt.Fprint(w, "Print session:\n")
_, _ = fmt.Fprint(w, string(js)+"\n")
return nil
}
package executor
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/gorcon/rcon"
"github.com/gorcon/rcon-cli/internal/config"
"github.com/gorcon/rcon-cli/internal/logger"
"github.com/gorcon/telnet"
"github.com/gorcon/websocket"
"github.com/urfave/cli/v2"
)
// CommandQuit is the command for exit from Interactive mode.
const CommandQuit = ":q"
// CommandsResponseSeparator is symbols that is written between responses of
// several commands if more than one command was called.
const CommandsResponseSeparator = "--------"
// Errors.
var (
// ErrEmptyAddress is returned when executed command without setting address
// in single mode.
ErrEmptyAddress = errors.New("address is not set: to set address add -a host:port")
// ErrEmptyPassword is returned when executed command without setting password
// in single mode.
ErrEmptyPassword = errors.New("password is not set: to set password add -p password")
// ErrCommandEmpty is returned when executed command length equal 0.
ErrCommandEmpty = errors.New("command is not set")
)
// ExecuteCloser is the interface that groups Execute and Close methods.
type ExecuteCloser interface {
Execute(command string) (string, error)
Close() error
}
// Executor is a cli commands execute wrapper.
type Executor struct {
version string
r io.Reader
w io.Writer
app *cli.App
client ExecuteCloser
}
// NewExecutor creates a new Executor.
func NewExecutor(r io.Reader, w io.Writer, version string) *Executor {
return &Executor{
version: version,
r: r,
w: w,
}
}
// Run is the entry point to the cli app.
func (executor *Executor) Run(arguments []string) error {
executor.init()
if err := executor.app.Run(arguments); err != nil && !errors.Is(err, flag.ErrHelp) {
return fmt.Errorf("cli: %w", err)
}
return nil
}
// NewSession parses os args and config file for connection details to
// a remote server. If the address and password flags were received the
// configuration file is ignored.
func (executor *Executor) NewSession(c *cli.Context) (*config.Session, error) {
ses := config.Session{
Address: c.String("address"),
Password: c.String("password"),
Type: c.String("type"),
Log: c.String("log"),
SkipErrors: c.Bool("skip"),
Timeout: c.Duration("timeout"),
Variables: c.Bool("variables"),
}
if ses.Address != "" && ses.Password != "" {
return &ses, nil
}
cfg, err := config.NewConfig(c.String("config"))
if err != nil {
return &ses, fmt.Errorf("config: %w", err)
}
env := c.String("env")
if env == "" {
env = config.DefaultConfigEnv
}
// Get variables from config environment if flags are not defined.
if ses.Address == "" {
ses.Address = (*cfg)[env].Address
}
if ses.Password == "" {
ses.Password = (*cfg)[env].Password
}
if ses.Log == "" {
ses.Log = (*cfg)[env].Log
}
if ses.Type == "" {
ses.Type = (*cfg)[env].Type
}
return &ses, nil
}
// Dial sends auth request for remote server. Returns en error if
// address or password is incorrect.
func (executor *Executor) Dial(ses *config.Session) error {
var err error
if executor.client == nil {
switch ses.Type {
case config.ProtocolTELNET:
executor.client, err = telnet.Dial(ses.Address, ses.Password, telnet.SetDialTimeout(ses.Timeout))
case config.ProtocolWebRCON:
executor.client, err = websocket.Dial(
ses.Address, ses.Password, websocket.SetDialTimeout(ses.Timeout), websocket.SetDeadline(ses.Timeout))
default:
executor.client, err = rcon.Dial(
ses.Address, ses.Password, rcon.SetDialTimeout(ses.Timeout), rcon.SetDeadline(ses.Timeout))
}
}
if err != nil {
executor.client = nil
return fmt.Errorf("auth: %w", err)
}
return nil
}
// Execute sends commands to Execute to the remote server and prints the response.
func (executor *Executor) Execute(w io.Writer, ses *config.Session, commands ...string) error {
if len(commands) == 0 {
return ErrCommandEmpty
}
// TODO: Check keep alive connection to web rcon.
if ses.Type == config.ProtocolWebRCON {
defer func() {
if executor.client != nil {
_ = executor.client.Close()
executor.client = nil
}
}()
}
if err := executor.Dial(ses); err != nil {
return fmt.Errorf("execute: %w", err)
}
for i, command := range commands {
if err := executor.execute(w, ses, command); err != nil {
return err
}
if i+1 != len(commands) {
_, _ = fmt.Fprintln(w, CommandsResponseSeparator)
}
}
return nil
}
// Interactive reads stdin, parses commands, executes them on remote server
// and prints the responses.
func (executor *Executor) Interactive(r io.Reader, w io.Writer, ses *config.Session) error {
if ses.Address == "" {
_, _ = fmt.Fprint(w, "Enter remote host and port [ip:port]: ")
_, _ = fmt.Fscanln(r, &ses.Address)
}
if ses.Password == "" {
_, _ = fmt.Fprint(w, "Enter password: ")
_, _ = fmt.Fscanln(r, &ses.Password)
}
if ses.Type == "" {
_, _ = fmt.Fprint(w, "Enter protocol type (empty for rcon): ")
_, _ = fmt.Fscanln(r, &ses.Type)
}
switch ses.Type {
case config.ProtocolTELNET:
return telnet.DialInteractive(r, w, ses.Address, ses.Password)
case "", config.ProtocolRCON, config.ProtocolWebRCON:
if err := executor.Dial(ses); err != nil {
return err
}
_, _ = fmt.Fprintf(w, "Waiting commands for %s (or type %s to exit)\n> ", ses.Address, CommandQuit)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
command := scanner.Text()
if command != "" {
if command == CommandQuit {
break
}
if err := executor.Execute(w, ses, command); err != nil {
return err
}
}
_, _ = fmt.Fprint(w, "> ")
}
default:
_, _ = fmt.Fprintf(w, "Unsupported protocol type (%q). Allowed %q, %q and %q protocols\n",
ses.Type, config.ProtocolRCON, config.ProtocolWebRCON, config.ProtocolTELNET)
}
return nil
}
// Close closes connection to remote server.
func (executor *Executor) Close() error {
if executor.client != nil {
return executor.client.Close()
}
return nil
}
// init creates a new cli Application.
func (executor *Executor) init() {
app := cli.NewApp()
app.Usage = "CLI for executing queries on a remote server"
app.Description = "Can be run in two modes - in the mode of a single query and in terminal mode of reading the " +
"input stream. \n\n" + "To run single mode type commands after options flags. Example: \n" +
filepath.Base(os.Args[0]) + " -a 127.0.0.1:16260 -p password command1 command2 \n\n" +
"To run terminal mode just do not specify commands to execute. Example: \n" +
filepath.Base(os.Args[0]) + " -a 127.0.0.1:16260 -p password"
app.Version = executor.version
app.Copyright = "Copyright (c) 2022 Pavel Korotkiy (outdead)"
app.HideHelpCommand = true
app.Flags = executor.getFlags()
app.Action = executor.action
executor.app = app
}
// getFlags returns CLI flags to parse.
func (executor *Executor) getFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "address",
Aliases: []string{"a"},
Usage: "Set host and port to remote server. Example 127.0.0.1:16260",
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"p"},
Usage: "Set password to remote server",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Specify type of connection",
Value: config.DefaultProtocol,
},
&cli.StringFlag{
Name: "log",
Aliases: []string{"l"},
Usage: "Path to the log file. If not specified it is taken from the config",
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Path to the configuration file",
Value: config.DefaultConfigName,
},
&cli.StringFlag{
Name: "env",
Aliases: []string{"e"},
Usage: "Config environment with server credentials",
Value: config.DefaultConfigEnv,
},
&cli.BoolFlag{
Name: "skip",
Aliases: []string{"s"},
Usage: "Skip errors and run next command",
},
&cli.DurationFlag{
Name: "timeout",
Aliases: []string{"T"},
Usage: "Set dial and execute timeout",
Value: config.DefaultTimeout,
},
&cli.BoolFlag{
Name: "variables",
Aliases: []string{"V"},
Usage: "Print stored variables and exit",
Value: false,
},
}
}
// action executes when no subcommands are specified.
func (executor *Executor) action(c *cli.Context) error {
ses, err := executor.NewSession(c)
if err != nil {
return err
}
if ses.Variables {
executor.printVariables(ses, c)
return nil
}
commands := c.Args().Slice()
if len(commands) == 0 {
return executor.Interactive(executor.r, executor.w, ses)
}
if ses.Address == "" {
return ErrEmptyAddress
}
if ses.Password == "" {
return ErrEmptyPassword
}
return executor.Execute(executor.w, ses, commands...)
}
// execute sends command to Execute to the remote server and prints the response.
func (executor *Executor) execute(w io.Writer, ses *config.Session, command string) error {
if command == "" {
return ErrCommandEmpty
}
var result string
var err error
result, err = executor.client.Execute(command)
if result != "" {
result = strings.TrimSpace(result)
_, _ = fmt.Fprintln(w, result)
}
if err != nil {
if ses.SkipErrors {
_, _ = fmt.Fprintln(w, fmt.Errorf("execute: %w", err))
} else {
return fmt.Errorf("execute: %w", err)
}
}
if err = logger.Write(ses.Log, ses.Address, command, result); err != nil {
_, _ = fmt.Fprintln(w, fmt.Errorf("log: %w", err))
}
return nil
}
func (executor *Executor) printVariables(ses *config.Session, c *cli.Context) {
_, _ = fmt.Fprint(executor.w, "Got Print Variables param.\n")
_ = ses.Print(executor.w)
_, _ = fmt.Fprint(executor.w, "\nPrint other variables:\n")
_, _ = fmt.Fprintf(executor.w, "Path to config file (if used): %s\n", c.String("config"))
_, _ = fmt.Fprintf(executor.w, "Cofig environment: %s\n", c.String("env"))
}
package logger
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// DefaultTimeLayout is layout for convert time.Now to String.
const DefaultTimeLayout = "2006-01-02 15:04:05"
// DefaultLineFormat is format to log line record.
const DefaultLineFormat = "[%s] %s: %s\n%s\n\n"
// ErrEmptyFileName is returned when trying to open file with empty name.
var ErrEmptyFileName = errors.New("empty file name")
// OpenFile opens file for append strings. Creates file if file not exist.
func OpenFile(name string) (*os.File, error) {
if name == "" {
return nil, ErrEmptyFileName
}
var file *os.File
switch _, err := os.Stat(name); {
case err == nil:
const perm = 0o666
file, err = os.OpenFile(name, os.O_APPEND|os.O_WRONLY, perm)
if err != nil {
return file, fmt.Errorf("open: %w", err)
}
case os.IsNotExist(err):
dir := filepath.Dir(name)
if _, err = os.Stat(dir); os.IsNotExist(err) {
const perm = 0o766
if err = os.MkdirAll(dir, perm); err != nil {
return file, fmt.Errorf("create directory: %w", err)
}
}
file, err = os.Create(name)
if err != nil {
return file, fmt.Errorf("create: %w", err)
}
}
return file, nil
}
// Write saves request and response to log file.
func Write(name string, address string, request string, response string) error {
// Disable logging if log file name is empty.
if name == "" {
return nil
}
file, err := OpenFile(name)
if err != nil {
return err
}
defer file.Close()
line := fmt.Sprintf(DefaultLineFormat, time.Now().Format(DefaultTimeLayout), address, request, response)
if _, err = file.WriteString(line); err != nil {
return fmt.Errorf("write: %w", err)
}
return nil
}