Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

11 changed files with 32 additions and 183 deletions

View File

@ -1,25 +0,0 @@
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: ./...

View File

@ -1,5 +1,5 @@
# mctl
mctl is a terminal-friendly remote console client
mctl is a terminal-friendly remote connection client
## Installation
Install mctl using golang
@ -10,7 +10,7 @@ go install code.jakeyoungdev.com/jake/mctl@main #it is recommended to use a tagg
## Setup
### Configuring mctl
mctl requires a one-time setup via the 'config' command before interacting with any servers, password is entered securely from the terminal and encrypted
mctl requires a one-time setup via the 'config' command, password is entered securely from the terminal
```bash
mctl config -s <serveraddress> -p <rconport>
```
@ -23,7 +23,7 @@ mctl login #makes auth request to server with saved password
```
### Sending commands
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
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
```
Logging into X.X.X.X on port 61695
Connected! Type 'mctl' to close
@ -33,7 +33,7 @@ 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:
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:
```bash
mctl save <name>
```
@ -43,10 +43,6 @@ 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:
@ -69,12 +65,6 @@ 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
### Commands
|Command|Description|
@ -83,7 +73,6 @@ mctl delete <name>
|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
@ -94,12 +83,10 @@ mctl delete <name>
|server|s|yes|RCon address|
### Configuration file
All configuration data will be kept in the home directory and any sensitive data is encrypted for added security
All configuration data will be kept in /home/.mctl.yaml or C:\\Users\\username\\.mctl.yaml, passwords are encrypted for an added layer of 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
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
## Development
this repo is currently in heavy development and may encounter breaking changes, use a tag to prevent any surprises

View File

@ -48,8 +48,7 @@ var configCmd = &cobra.Command{
viper.Set("server", cfgserver)
viper.Set("password", string(ciphert))
viper.Set("port", cfgport)
err = viper.WriteConfig()
cobra.CheckErr(err)
viper.WriteConfig()
fmt.Println()
fmt.Println("Config file updated!")
},
@ -58,11 +57,9 @@ var configCmd = &cobra.Command{
func init() {
initConfig()
configCmd.Flags().StringVarP(&cfgserver, "server", "s", "", "server address")
err := configCmd.MarkFlagRequired("server")
cobra.CheckErr(err)
configCmd.MarkFlagRequired("server")
configCmd.Flags().IntVarP(&cfgport, "port", "p", 0, "server rcon port")
err = configCmd.MarkFlagRequired("port")
cobra.CheckErr(err)
configCmd.MarkFlagRequired("port")
rootCmd.AddCommand(configCmd)
}
@ -75,8 +72,7 @@ func initConfig() {
viper.SetConfigType("yaml")
viper.SetConfigName(".mctl")
viper.AutomaticEnv()
err = viper.ReadInConfig()
cobra.CheckErr(err)
viper.ReadInConfig()
if err := viper.ReadInConfig(); err != nil {
//file does not exist, create it
@ -90,13 +86,7 @@ func initConfig() {
_, err := rand.Read(uu)
cobra.CheckErr(err)
//create custom command map
cmdMap := make(map[string]any, 0)
//write config
viper.Set("customcmd", cmdMap)
viper.Set("device", string(uu))
err = viper.SafeWriteConfig()
cobra.CheckErr(err)
viper.SafeWriteConfig()
}
}

View File

@ -1,39 +0,0 @@
/*
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,7 +5,6 @@ package cmd
import (
"bufio"
"errors"
"fmt"
"os"
@ -18,7 +17,6 @@ import (
var loginCmd = &cobra.Command{
Use: "login",
Example: "mctl login",
SilenceUsage: true,
Short: "Login to server and send commands",
Long: `Login to server using saved config and enter command loop
sending commands to server and printing the response.`,
@ -56,14 +54,6 @@ var loginCmd = &cobra.Command{
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() {

View File

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

View File

@ -17,19 +17,12 @@ import (
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
}
//grab saved command
cmdName := viper.Get(fmt.Sprintf("customCmd-%s", args[0])).(string)
//convert arguments to interface
var nargs []any
for _, a := range args[1:] {
@ -37,7 +30,7 @@ var runCmd = &cobra.Command{
}
//inject arguments
fixed := fmt.Sprintf(cmdRun.(string), nargs...)
fixed := fmt.Sprintf(cmdName, nargs...)
fmt.Printf("Running saved command %s\n", fixed)
//create client and send command
@ -51,24 +44,13 @@ var runCmd = &cobra.Command{
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")
cmdCheck := viper.Get(fmt.Sprintf("customCmd-%s", args[0]))
count := strings.Count(cmdCheck.(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)

View File

@ -27,18 +27,10 @@ var saveCmd = &cobra.Command{
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)
viper.Set(fmt.Sprintf("customCmd-%s", args[0]), txt)
viper.WriteConfig()
fmt.Println("\nSaved!")
return
}
}
},

View File

@ -6,7 +6,6 @@ package cmd
import (
"errors"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -17,34 +16,9 @@ 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`,
Long: `Load command using the supplied name and displays it in the terminal`,
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))
fmt.Printf("\nCommand: %s\n", viper.Get(args[0]))
},
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {

View File

@ -21,10 +21,7 @@ func EncryptPassword(b []byte) ([]byte, error) {
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
ct := aesg.Seal(nil, []byte(nonce), []byte(b), nil)
return ct, nil
}

2
go.mod
View File

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