4 Commits

Author SHA1 Message Date
8daa621528 delete added, more updates see below
- 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
2025-04-17 17:09:16 -04:00
7f090dbce7 update readme and version bump 2025-04-17 11:37:37 -04:00
f943639a88 lots o updates
- moved mcr code to package
- added the save, run, and view commands
- commands can now be saved
- saved commands support placeholders
2025-04-17 11:20:39 -04:00
bef3521770 subcommands and structure
- added run, save, and view subcommands
- WIP still
- moved encryption and decryption to util file
- a good start
2025-04-16 19:25:21 -04:00
13 changed files with 41 additions and 134 deletions

View File

@@ -1,11 +0,0 @@
name: "code scans"
on: pull_request
jobs:
scans:
runs-on: test
steps:
- uses: actions/checkout@v4
- name: "dependency scan and static code analysis"
uses: https://code.jakeyoungdev.com/actions/donotpassgo@v1.0.0

View File

@@ -1,22 +1,16 @@
# mctl # mctl
mctl is a terminal-friendly remote console client mctl is a terminal-friendly remote connection client
## Index
1. [Installation](#installation)
2. [Setup](#setup)
3. [Documentation](#documentation)
4. [Security](#security)
5. [Development](#development)
## 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 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 before interacting with any servers, password is entered securely from the terminal and encrypted mctl requires a one-time setup via the 'config' command before interacting with any servers, password is entered securely from the terminal
```bash ```bash
mctl config -s <serveraddress> -p <rconport> mctl config -s <serveraddress> -p <rconport>
``` ```
@@ -29,7 +23,7 @@ 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. 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 Logging into X.X.X.X on port 61695
Connected! Type 'mctl' to close Connected! Type 'mctl' to close
@@ -39,7 +33,7 @@ There are 0 of a max of 20 players online:
``` ```
### Saving commands ### 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 ```bash
mctl save <name> mctl save <name>
``` ```
@@ -49,10 +43,6 @@ Saved commands can be viewed with:
```bash ```bash
mctl view <name> mctl view <name>
``` ```
All saved commands can be viewed with:
```bash
mctl view all
```
### Running saved commands ### Running saved commands
Commands that have been saved can be run with: Commands that have been saved can be run with:
@@ -81,13 +71,6 @@ Commands can be deleted with:
mctl delete <name> mctl delete <name>
``` ```
### Clear configuration file
To clear all fields from the configuration file use:
```bash
#CAUTION: If the config file is cleared all data previously saved will be lost forever
mctl clear
```
## Documentation ## Documentation
### Commands ### Commands
|Command|Description| |Command|Description|
@@ -98,7 +81,6 @@ mctl clear
|view \<name>|displays saved command| |view \<name>|displays saved command|
|delete \<name>|deletes saved command| |delete \<name>|deletes saved command|
|run \<name> args...|runs saved command filling placeholders with supplied args| |run \<name> args...|runs saved command filling placeholders with supplied args|
|clear|clears config file|
### Flags ### Flags
#### config #### config
@@ -108,12 +90,10 @@ mctl clear
|server|s|yes|RCon address| |server|s|yes|RCon address|
### Configuration file ### 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 ## 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. 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 development and may encounter breaking changes, use a tag to prevent any surprises this repo is currently in heavy development and may encounter breaking changes, use a tag to prevent any surprises

View File

@@ -1,43 +0,0 @@
/*
Copyright © 2025 Jake jake.young.dev@gmail.com
*/
package cmd
import (
"fmt"
"os"
"code.jakeyoungdev.com/jake/mctl/models"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// clearCmd represents the clear command
var clearCmd = &cobra.Command{
Use: "clear",
Short: "Clear config file",
Long: `Clears all configuration values for mctl, all server configuration will be lost`,
Run: func(cmd *cobra.Command, args []string) {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".mctl")
err = viper.ReadInConfig()
if err == nil {
//clear values if file exists
for _, v := range models.ConfigFields {
viper.Set(v, "")
}
err := viper.WriteConfig()
cobra.CheckErr(err)
fmt.Println("Config file cleared, use 'config' command to re-populate it")
}
},
}
func init() {
rootCmd.AddCommand(clearCmd)
}

View File

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

View File

@@ -1,5 +1,5 @@
/* /*
Copyright © 2025 Jake jake.young.dev@gmail.com Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/ */
package cmd package cmd
@@ -17,13 +17,16 @@ var deleteCmd = &cobra.Command{
Short: "Delete a saved command", Short: "Delete a saved command",
Long: `Deletes a command stored using the save command`, Long: `Deletes a command stored using the save command`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if viper.IsSet("customcmd") { var cm map[string]any
cmdMap := viper.Get("customcmd").(map[string]any) cmdMap := viper.Get("customcmd")
delete(cmdMap, args[0]) if cmdMap == nil {
viper.Set("customcmd", cmdMap) cm = make(map[string]any, 0)
err := viper.WriteConfig() } else {
cobra.CheckErr(err) cm = cmdMap.(map[string]any)
} }
delete(cm, args[0])
viper.Set("customcmd", cmdMap)
viper.WriteConfig()
}, },
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {

View File

@@ -58,10 +58,9 @@ var loginCmd = &cobra.Command{
}, },
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
//ensure config command has been run //ensure config command has been run
if !viper.IsSet("server") || !viper.IsSet("password") || !viper.IsSet("port") { if viper.Get("server") == "" || viper.Get("password") == "" || viper.Get("port") == 0 {
return errors.New("the 'config' command must be run before you can interact with servers") return errors.New("the 'config' command must be run before you can interact with servers")
} }
return nil return nil
}, },
} }

View File

@@ -14,7 +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", Version: "v0.2.0",
// Run: func(cmd *cobra.Command, args []string) { }, // Run: func(cmd *cobra.Command, args []string) { },
} }

View File

@@ -22,7 +22,14 @@ var runCmd = &cobra.Command{
Long: `Loads a saved command, injects the supplied arguments into the command, and sends the command to the remove server Long: `Loads a saved command, injects the supplied arguments into the command, and sends the command to the remove server
printing the response`, printing the response`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
cm := viper.Get("customcmd").(map[string]any) //check for command map
var cm map[string]any
cmdMap := viper.Get("customcmd")
if cmdMap == nil {
cm = make(map[string]any, 0)
} else {
cm = cmdMap.(map[string]any)
}
//is this an existing command //is this an existing command
cmdRun, ok := cm[args[0]] cmdRun, ok := cm[args[0]]
if !ok { if !ok {
@@ -51,15 +58,11 @@ var runCmd = &cobra.Command{
fmt.Println(res) fmt.Println(res)
}, },
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
//ensure config command has been run //ensure configuration has been setup
if !viper.IsSet("server") || !viper.IsSet("password") || !viper.IsSet("port") { if viper.Get("server") == "" || viper.Get("password") == "" || viper.Get("port") == 0 {
return errors.New("the 'config' command must be run before you can interact with servers") 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 //ensure we have a command name
al := len(args) al := len(args)
if al == 0 { if al == 0 {

View File

@@ -36,8 +36,7 @@ var saveCmd = &cobra.Command{
} }
cmdMap[args[0]] = txt cmdMap[args[0]] = txt
viper.Set("customcmd", cmdMap) viper.Set("customcmd", cmdMap)
err := viper.WriteConfig() viper.WriteConfig()
cobra.CheckErr(err)
fmt.Println("\nSaved!") fmt.Println("\nSaved!")
} }
} }

View File

@@ -6,7 +6,6 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -17,7 +16,7 @@ var viewCmd = &cobra.Command{
Use: "view <name>", Use: "view <name>",
Example: "mctl view test", Example: "mctl view test",
Short: "View saved commands", 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) { Run: func(cmd *cobra.Command, args []string) {
var cm map[string]any var cm map[string]any
cmdMap := viper.Get("customcmd") cmdMap := viper.Get("customcmd")
@@ -27,24 +26,13 @@ var viewCmd = &cobra.Command{
} }
cm = cmdMap.(map[string]any) 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]] custom, ok := cm[args[0]]
if !ok { if !ok {
fmt.Println("command not found") fmt.Println("command not found")
return return
} }
fmt.Printf("Command: %s\n", custom.(string)) fmt.Printf("Command: %s", custom.(string))
}, },
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {

View File

@@ -21,10 +21,7 @@ func EncryptPassword(b []byte) ([]byte, error) {
return nil, err 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 ct := aesg.Seal(nil, []byte(nonce), []byte(b), nil)
//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 return ct, 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.2 go 1.24.0
require ( require (
github.com/jake-young-dev/mcr v1.3.1 github.com/jake-young-dev/mcr v1.3.1

View File

@@ -1,4 +0,0 @@
package models
//list of all fields kept in config file
var ConfigFields = [6]string{"customcmd", "device", "nonce", "port", "server", "password"}