Compare commits

..

9 Commits
v0.1.0 ... main

Author SHA1 Message Date
26c50085d6 new/pipeline (#6)
All checks were successful
code scans / scans (push) Successful in 1m27s
Reviewed-on: #6
Co-authored-by: jake <jake.young.dev@gmail.com>
Co-committed-by: jake <jake.young.dev@gmail.com>
2025-04-24 18:22:16 +00:00
13d3b2cef3 feature/view-all (#5)
Reviewed-on: #5
Co-authored-by: jake <jake.young.dev@gmail.com>
Co-committed-by: jake <jake.young.dev@gmail.com>
2025-04-20 05:28:56 +00:00
4100762986 Update README.md 2025-04-19 05:18:26 +00:00
a3527d3388 dev/standards (#4)
Reviewed-on: #4
Co-authored-by: jake <jake.young.dev@gmail.com>
Co-committed-by: jake <jake.young.dev@gmail.com>
2025-04-19 05:13:53 +00:00
35fb9fc270 version bump 2025-04-17 17:34:32 -04:00
6fe91f816b view command response fix 2025-04-17 17:32:16 -04:00
f8a9528e0f delete added, more details below (#3)
- readme updates
- delete command added
- commands now saved in a map vs fields
- login and run now ensure config has been run to prevent errors

Reviewed-on: #3
Co-authored-by: jake <jake.young.dev@gmail.com>
Co-committed-by: jake <jake.young.dev@gmail.com>
2025-04-17 21:25:54 +00:00
a9c6400761 dev/command-saving (#1)
Reviewed-on: #1
Co-authored-by: jake <jake.young.dev@gmail.com>
Co-committed-by: jake <jake.young.dev@gmail.com>
2025-04-17 15:40:54 +00:00
8068b090ed adding version into command 2025-04-16 16:43:58 -04:00
13 changed files with 495 additions and 68 deletions

View File

@ -0,0 +1,25 @@
name: "code scans"
on: [push, pull_request] #runs on pushes to any branch
jobs:
scans:
runs-on: smoke-test
steps:
- name: "clone code"
uses: actions/checkout@v4
- name: "install go"
uses: https://code.jakeyoungdev.com/actions/install-go@v0.1.3
with:
commands: |
golang.org/x/vuln/cmd/govulncheck@latest
- name: "dependency and stdlib scan"
uses: https://code.jakeyoungdev.com/actions/report-vulns@master
with:
manager: go
- name: "static code analysis"
uses: securego/gosec@master
with:
args: ./...

2
.gitignore vendored
View File

@ -24,4 +24,4 @@ go.work.sum
# env file # env file
.env .env
todo.txt

View File

@ -1,15 +1,16 @@
# mctl # mctl
mctl is a terminal-friendly remote connection client mctl is a terminal-friendly remote console client
## Installation ## Installation
Install mctl using golang Install mctl using golang
```bash ```bash
go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version to prevent pulling unwanted changes go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagged version
``` ```
<br />
## Setup ## Setup
### Configuring mctl ### Configuring mctl
mctl requires a one-time setup via the 'config' command, password is entered securely from the terminal mctl requires a one-time setup via the 'config' command before interacting with any servers, password is entered securely from the terminal and encrypted
```bash ```bash
mctl config -s <serveraddress> -p <rconport> mctl config -s <serveraddress> -p <rconport>
``` ```
@ -22,7 +23,57 @@ mctl login #makes auth request to server with saved password
``` ```
### Sending commands ### Sending commands
If login is successful the app will enter the command loop, which allows commands to be sent directly to the server until 'mctl' is sent. Commands are sent as-is to the server, there is no validation of command syntax within mctl If login is successful the app will enter the command loop, which allows commands to be sent directly to the server. Commands are sent as-is to the server, there is no validation of command syntax within mctl
```
Logging into X.X.X.X on port 61695
Connected! Type 'mctl' to close
RCON@X.X.X.X /> list
There are 0 of a max of 20 players online:
```
### Saving commands
Commands can be saved under an alias for quick execution later, saved commands can contain placeholders '%s' that can be populated at runtime to allow for commands with unique runtime args to still be saved, see [example](#saving-and-running-example) for more:
```bash
mctl save <name>
```
### Viewing commands
Saved commands can be viewed with:
```bash
mctl view <name>
```
All saved commands can be viewed with:
```bash
mctl view all
```
### Running saved commands
Commands that have been saved can be run with:
```bash
mctl run <name>
```
If the saved command contains placeholders, the necessary arguments must be supplied:
```bash
mctl run <name> args...
```
### Saving and running example
```bash
#saving command named "test" to run "tp %s 0 0 0"
mctl save test
Command: tp %s 0 0 0
#run command on user "jake"
mctl run test jake
#will run: tp jake 0 0 0 on remote server
```
### Delete saved command
Commands can be deleted with:
```bash
mctl delete <name>
```
## Documentation ## Documentation
### Commands ### Commands
@ -30,6 +81,10 @@ If login is successful the app will enter the command loop, which allows command
|---|---| |---|---|
|config|used to update the config file| |config|used to update the config file|
|login|makes connection request to the server using saved configuration and enters command loop| |login|makes connection request to the server using saved configuration and enters command loop|
|save \<name>|saves specific command for reuse|
|view \<name>|displays saved command|
|delete \<name>|deletes saved command|
|run \<name> args...|runs saved command filling placeholders with supplied args|
### Flags ### Flags
#### config #### config
@ -38,11 +93,13 @@ If login is successful the app will enter the command loop, which allows command
|port|p|yes|RCon port| |port|p|yes|RCon port|
|server|s|yes|RCon address| |server|s|yes|RCon address|
#### login
no flags
### Configuration file ### Configuration file
All configuration data will be kept in /home/.mctl.yaml, passwords are encrypted for an added layer of security All configuration data will be kept in the home directory and any sensitive data is encrypted for added security
## Security
RCon is an inherently insecure protocol, passwords are sent in plaintext and, if possible, the port should not be exposed to the internet. It is best to keep these connections local or over a VPN.
mctl utilizes [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) and [gosec](https://github.com/securego/gosec) in workflows to ensure quality, secure code is being pushed. These workflow steps must pass before a PR will be accepted
## Development ## Development
this repo is currently in heavy development and may encounter breaking changes this repo is currently in heavy development and may encounter breaking changes, use a tag to prevent any surprises

59
client/mcr.go Normal file
View File

@ -0,0 +1,59 @@
package client
import (
"fmt"
"code.jakeyoungdev.com/jake/mctl/cryptography"
"github.com/jake-young-dev/mcr"
"github.com/spf13/viper"
)
/*
This is a simple wrapper for the MCR client to provide easy use of mcr without having to manually
decrypt the password/hit viper each time.
*/
type Client struct {
cli *mcr.Client
}
type IClient interface {
Close()
Command(cmd string) (string, error)
}
// creates a new mcr client using saved credentials and decrypted password
func New() (*Client, error) {
//grab saved credentials
server := viper.Get("server").(string)
password := viper.Get("password").(string)
port := viper.Get("port").(int)
fmt.Printf("Logging into %s on port %d\n", server, port)
//decrypt password
pt, err := cryptography.DecryptPassword([]byte(password))
if err != nil {
return nil, err
}
//connect to game server
cli := mcr.NewClient(server, mcr.WithPort(port))
err = cli.Connect(string(pt))
if err != nil {
return nil, err
}
return &Client{
cli: cli,
}, nil
}
// closes client connection
func (c *Client) Close() error {
return c.cli.Close()
}
// sends command to server, only exists to prevent exposing cli field
func (c *Client) Command(cmd string) (string, error) {
return c.cli.Command(cmd)
}

View File

@ -4,26 +4,26 @@ Copyright © 2025 Jake jake.young.dev@gmail.com
package cmd package cmd
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"io" "io"
"os" "os"
"code.jakeyoungdev.com/jake/mctl/cryptography"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/term" "golang.org/x/term"
) )
var ( var (
server string cfgserver string
port int cfgport int
) )
// configCmd represents the config command // configCmd represents the config command
var configCmd = &cobra.Command{ var configCmd = &cobra.Command{
Use: "config", Use: "config",
Example: "mctl config -s x.x.x.x -p 61695",
Short: "Create and populate config file", Short: "Create and populate config file",
Long: `Creates the .mctl file in the user home directory Long: `Creates the .mctl file in the user home directory
populating it with the server address, rcon password, and populating it with the server address, rcon password, and
@ -34,25 +34,22 @@ var configCmd = &cobra.Command{
ps, err := term.ReadPassword(int(os.Stdin.Fd())) ps, err := term.ReadPassword(int(os.Stdin.Fd()))
cobra.CheckErr(err) cobra.CheckErr(err)
//setup aes encrypter
block, err := aes.NewCipher([]byte(viper.Get("device").(string)))
cobra.CheckErr(err)
//generate and apply random nonce //generate and apply random nonce
nonce := make([]byte, 12) nonce := make([]byte, 12)
_, err = io.ReadFull(rand.Reader, nonce) _, err = io.ReadFull(rand.Reader, nonce)
cobra.CheckErr(err) cobra.CheckErr(err)
aesg, err := cipher.NewGCM(block) viper.Set("nonce", string(nonce))
//encrypt password
ciphert, err := cryptography.EncryptPassword(ps)
cobra.CheckErr(err) cobra.CheckErr(err)
//encrypt rcon password
ciphert := aesg.Seal(nil, nonce, ps, nil)
//update config file with new values //update config file with new values
viper.Set("server", server) viper.Set("server", cfgserver)
viper.Set("password", string(ciphert)) viper.Set("password", string(ciphert))
viper.Set("port", port) viper.Set("port", cfgport)
viper.Set("nonce", string(nonce)) err = viper.WriteConfig()
viper.WriteConfig() cobra.CheckErr(err)
fmt.Println() fmt.Println()
fmt.Println("Config file updated!") fmt.Println("Config file updated!")
}, },
@ -60,13 +57,16 @@ var configCmd = &cobra.Command{
func init() { func init() {
initConfig() initConfig()
configCmd.Flags().StringVarP(&server, "server", "s", "", "server address") configCmd.Flags().StringVarP(&cfgserver, "server", "s", "", "server address")
configCmd.MarkFlagRequired("server") err := configCmd.MarkFlagRequired("server")
configCmd.Flags().IntVarP(&port, "port", "p", 0, "server rcon port") cobra.CheckErr(err)
configCmd.MarkFlagRequired("port") configCmd.Flags().IntVarP(&cfgport, "port", "p", 0, "server rcon port")
err = configCmd.MarkFlagRequired("port")
cobra.CheckErr(err)
rootCmd.AddCommand(configCmd) rootCmd.AddCommand(configCmd)
} }
// init config sets viper config and checks for config file, creating it if it doesn't exist
func initConfig() { func initConfig() {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
cobra.CheckErr(err) cobra.CheckErr(err)
@ -74,15 +74,15 @@ func initConfig() {
viper.AddConfigPath(home) viper.AddConfigPath(home)
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigName(".mctl") viper.SetConfigName(".mctl")
viper.AutomaticEnv() viper.AutomaticEnv()
viper.ReadInConfig() err = viper.ReadInConfig()
cobra.CheckErr(err)
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
//file does not exist, create it //file does not exist, create it
viper.Set("server", server) viper.Set("server", cfgserver)
viper.Set("password", "") viper.Set("password", "")
viper.Set("port", port) viper.Set("port", cfgport)
viper.Set("nonce", "") viper.Set("nonce", "")
//generate psuedo-random key //generate psuedo-random key
@ -90,7 +90,13 @@ func initConfig() {
_, err := rand.Read(uu) _, err := rand.Read(uu)
cobra.CheckErr(err) cobra.CheckErr(err)
//create custom command map
cmdMap := make(map[string]any, 0)
//write config
viper.Set("customcmd", cmdMap)
viper.Set("device", string(uu)) viper.Set("device", string(uu))
viper.SafeWriteConfig() err = viper.SafeWriteConfig()
cobra.CheckErr(err)
} }
} }

39
cmd/delete.go Normal file
View File

@ -0,0 +1,39 @@
/*
Copyright © 2025 Jake jake.young.dev@gmail.com
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// deleteCmd represents the delete command
var deleteCmd = &cobra.Command{
Use: "delete <name>",
Example: "mctl delete newcmd",
Short: "Delete a saved command",
Long: `Deletes a command stored using the save command`,
Run: func(cmd *cobra.Command, args []string) {
if viper.IsSet("customcmd") {
cmdMap := viper.Get("customcmd").(map[string]any)
delete(cmdMap, args[0])
viper.Set("customcmd", cmdMap)
err := viper.WriteConfig()
cobra.CheckErr(err)
}
},
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("name argument is required")
}
return nil
},
}
func init() {
rootCmd.AddCommand(deleteCmd)
}

View File

@ -5,12 +5,11 @@ package cmd
import ( import (
"bufio" "bufio"
"crypto/aes" "errors"
"crypto/cipher"
"fmt" "fmt"
"os" "os"
"github.com/jake-young-dev/mcr" "code.jakeyoungdev.com/jake/mctl/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -18,32 +17,15 @@ import (
// loginCmd represents the login command // loginCmd represents the login command
var loginCmd = &cobra.Command{ var loginCmd = &cobra.Command{
Use: "login", Use: "login",
Example: "mctl login",
SilenceUsage: true,
Short: "Login to server and send commands", Short: "Login to server and send commands",
Long: `Login to server using saved config and enter command loop Long: `Login to server using saved config and enter command loop
sending commands to server and printing the response.`, sending commands to server and printing the response.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
//grab saved credentials //grab saved credentials
server := viper.Get("server") server := viper.Get("server").(string)
password := viper.Get("password") cli, err := client.New()
port := viper.Get("port")
fmt.Printf("Logging into %s on port %d\n", server, port)
//setup decrypter
nonce := viper.Get("nonce")
block, err := aes.NewCipher([]byte(viper.Get("device").(string)))
cobra.CheckErr(err)
aesg, err := cipher.NewGCM(block)
cobra.CheckErr(err)
//decrypt password
pwd := []byte(password.(string))
nn := []byte(nonce.(string))
pt, err := aesg.Open(nil, nn, pwd, nil)
cobra.CheckErr(err)
//connect to game server
cli := mcr.NewClient(server.(string), mcr.WithPort(port.(int)))
err = cli.Connect(string(pt))
cobra.CheckErr(err) cobra.CheckErr(err)
defer cli.Close() defer cli.Close()
@ -61,6 +43,7 @@ var loginCmd = &cobra.Command{
continue continue
} }
//mctl exits command terminal
if runningCmd == "mctl" { if runningCmd == "mctl" {
break break
} }
@ -73,6 +56,14 @@ var loginCmd = &cobra.Command{
fmt.Printf("Disconnected from %s\n", server) fmt.Printf("Disconnected from %s\n", server)
}, },
PreRunE: func(cmd *cobra.Command, args []string) error {
//ensure config command has been run
if !viper.IsSet("server") || !viper.IsSet("password") || !viper.IsSet("port") {
return errors.New("the 'config' command must be run before you can interact with servers")
}
return nil
},
} }
func init() { func init() {

View File

@ -14,6 +14,7 @@ var rootCmd = &cobra.Command{
Use: "mctl", Use: "mctl",
Short: "A remote console client", Short: "A remote console client",
Long: `mctl is a terminal-friendly remote console client made to manage game servers.`, Long: `mctl is a terminal-friendly remote console client made to manage game servers.`,
Version: "v0.3.4",
// Run: func(cmd *cobra.Command, args []string) { }, // Run: func(cmd *cobra.Command, args []string) { },
} }
@ -27,6 +28,5 @@ func Execute() {
} }
func init() { func init() {
//dont show completion subcommand in help message, makes the syntax confusing
rootCmd.Root().CompletionOptions.DisableDefaultCmd = true
} }

82
cmd/run.go Normal file
View File

@ -0,0 +1,82 @@
/*
Copyright © 2025 Jake jake.young.dev@gmail.com
*/
package cmd
import (
"errors"
"fmt"
"strings"
"code.jakeyoungdev.com/jake/mctl/client"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// runCmd represents the run command
var runCmd = &cobra.Command{
Use: "run <name> args...",
Example: "mctl run savedcmd 63 jake",
SilenceUsage: true,
Short: "Runs a previously saved command with supplied arguments on remote server",
Long: `Loads a saved command, injects the supplied arguments into the command, and sends the command to the remove server
printing the response`,
Run: func(cmd *cobra.Command, args []string) {
cm := viper.Get("customcmd").(map[string]any)
//is this an existing command
cmdRun, ok := cm[args[0]]
if !ok {
fmt.Printf("command %s not found", args[0])
return
}
//convert arguments to interface
var nargs []any
for _, a := range args[1:] {
nargs = append(nargs, a)
}
//inject arguments
fixed := fmt.Sprintf(cmdRun.(string), nargs...)
fmt.Printf("Running saved command %s\n", fixed)
//create client and send command
cli, err := client.New()
cobra.CheckErr(err)
defer cli.Close()
res, err := cli.Command(fixed)
cobra.CheckErr(err)
fmt.Println(res)
},
PreRunE: func(cmd *cobra.Command, args []string) error {
//ensure config command has been run
if !viper.IsSet("server") || !viper.IsSet("password") || !viper.IsSet("port") {
return errors.New("the 'config' command must be run before you can interact with servers")
}
if !viper.IsSet("customcmd") {
return errors.New("no saved commands to run")
}
//ensure we have a command name
al := len(args)
if al == 0 {
return errors.New("name argument is required")
}
cmdMap := viper.Get("customcmd").(map[string]any)
count := strings.Count(cmdMap[args[0]].(string), "%s")
//make sure enough arguments are sent to fill command placeholders
if al < count+1 {
return fmt.Errorf("not enough arguments to populate command. Supplied: %d, Needed: %d", al-1, count)
}
return nil
},
}
func init() {
rootCmd.AddCommand(runCmd)
}

56
cmd/save.go Normal file
View File

@ -0,0 +1,56 @@
/*
Copyright © 2025 Jake jake.young.dev@gmail.com
*/
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// saveCmd represents the save command
var saveCmd = &cobra.Command{
Use: "save <name>",
Example: "mctl save newcmd",
Short: "Saves a server command under an alias for quick execution",
Long: `Saves supplied command using alias <name> to allow the command to be executed using the run command. The %s placeholder can be
used as a wildcard to be injected when running the command`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Use %s as a wildcard in your command\n", "%s") //this is ugly, have to use printf to stop issues with compiler
fmt.Printf("Command: ")
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() {
txt := sc.Text()
if txt != "" {
var cmdMap map[string]any
cm := viper.Get("customcmd")
if cmdMap == nil {
cmdMap = make(map[string]any, 0)
} else {
cmdMap = cm.(map[string]any)
}
cmdMap[args[0]] = txt
viper.Set("customcmd", cmdMap)
err := viper.WriteConfig()
cobra.CheckErr(err)
fmt.Println("\nSaved!")
}
}
},
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("name argument is required")
}
return nil
},
}
func init() {
rootCmd.AddCommand(saveCmd)
}

60
cmd/view.go Normal file
View File

@ -0,0 +1,60 @@
/*
Copyright © 2025 Jake jake.young.dev@gmail.com
*/
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// viewCmd represents the view command
var viewCmd = &cobra.Command{
Use: "view <name>",
Example: "mctl view test",
Short: "View saved commands",
Long: `Load command using the supplied name and displays it in the terminal, 'all' will list every saved command`,
Run: func(cmd *cobra.Command, args []string) {
var cm map[string]any
cmdMap := viper.Get("customcmd")
if cmdMap == nil {
fmt.Println("no custom commands found")
return
}
cm = cmdMap.(map[string]any)
if strings.EqualFold(args[0], "all") {
//show all commands
fmt.Println("\nCommands: ")
for k, v := range cm {
fmt.Printf("%s - %s\n", k, v)
}
fmt.Println()
return
}
custom, ok := cm[args[0]]
if !ok {
fmt.Println("command not found")
return
}
fmt.Printf("Command: %s\n", custom.(string))
},
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("name argument is required")
}
return nil
},
}
func init() {
rootCmd.AddCommand(viewCmd)
}

52
cryptography/aes.go Normal file
View File

@ -0,0 +1,52 @@
package cryptography
import (
"crypto/aes"
"crypto/cipher"
"github.com/spf13/viper"
)
func EncryptPassword(b []byte) ([]byte, error) {
nonce := viper.Get("nonce").(string)
dev := viper.Get("device").(string)
block, err := aes.NewCipher([]byte(dev))
if err != nil {
return nil, err
}
aesg, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
//adding #nosec trigger here since gosec interprets this as a hardcoded nonce value. The nonce is calculated using crypto/rand when the
//config command is ran and is pulled from memory when used any times after, for now we must prevent the scan from catching here until gosec
//is updated to account for this properly
ct := aesg.Seal(nil, []byte(nonce), []byte(b), nil) // #nosec
return ct, nil
}
func DecryptPassword(b []byte) (string, error) {
nonce := viper.Get("nonce").(string)
password := viper.Get("password").(string)
dev := viper.Get("device").(string)
block, err := aes.NewCipher([]byte(dev))
if err != nil {
return "", err
}
aesg, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
op, err := aesg.Open(nil, []byte(nonce), []byte(password), nil)
if err != nil {
return "", err
}
return string(op), nil
}

2
go.mod
View File

@ -1,6 +1,6 @@
module code.jakeyoungdev.com/jake/mctl module code.jakeyoungdev.com/jake/mctl
go 1.24.0 go 1.24.2
require ( require (
github.com/jake-young-dev/mcr v1.3.1 github.com/jake-young-dev/mcr v1.3.1