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 }