I don't know about you, but I really hate doing the same thing over and over again. Turns out, configuring new workstations and installing the required software is often just that, doing the same thing over and over again.

I was doing this project for a client, where we had to install 13 different piece of software on every computer, which is pretty time consuming when applied to tens of workstations.

Arguably, something probably exists to do just that, but Go 1.16 with the go:embed directive just came out, and I was very excited to try it out. So I sat down in front of my computer, put on some music, and started coding.

My requirements were the following:

  1. I needed to be able to run multiple different kind of installers, be it .exe or .msi
  2. Some software had external dependencies
  3. Some installers depended on directories of files or other files
  4. It had to compile to 1 .exe that had everything inside it

All of this is available on our GitHub, as always.

First, let's look at the type of configuration file I wanted:

Actions:
  - Name: "GoogleChrome"
    Kind: "msi"
    Path: "Chrome.msi"
    Args:
      - "/norestart"
  - Name: "BarracudaNAC" #For VPN Users
    Kind: "msi"
    Path: "nac.msi"
    Args:
      - "/norestart"
  - Name: "SentinelOne"
    Kind: "exe"
    Path: "sentinelone.exe"
    Args:
      - "/SITE_TOKEN=" #SentinelOne Token
      - "/SILENT"
  - Name: "RASClient"
    Kind: "msi"
    Path: "2xclient.msi"
    Ressources:
      - "settings.2xc" #Preconfigured RAS Client file
    Args:
      - "DEFSETTINGS=${RESPATH}settings.2xc"
      - "/norestart"
  - Name: "Office365"
    Kind: "pwsh"
    Path: "InstallOffice365.ps1"
  - Name: "ScreenConnect"
    Kind: "exe"
    Path: "ScreenConnect-setup.exe"

So where do we store our stuff? Here's the directory structure that would accompany this:

- /
---- ressources/
---------------- Chrome.msi
---------------- nac.msi
---------------- sentinelone.exe
---------------- 2xclient.msi
---------------- settings.2xc
---------------- InstallOffice365.ps1
---------------- ScreenConnect-setup.exe
---- config.yml
---- main.go
---- config.go
---- file-ops.go
---- program-parseargs.go
---- program.go

Pretty straight forward yes? Add more after that, and more will execute. To accomplish this, I made a simple "Program" struct.

type Program struct {
	Name       string   `yaml:"Name"`
	Kind       string   `yaml:"Kind"` //exe, pwsh, msi
	Args       []string `yaml:"Args"`
	Ressources []string `yaml:"Ressources"`
	Path       string   `yaml:"Path"`
}

Then we need some simple code to parse the configuration

type Config struct {
	Actions []Program `yaml:"Actions"`
}

func ParseConfig(c []byte) *Config {
	color.Cyan("Now parsing configuration information..")
	cfg := &Config{}
	err := yaml.Unmarshal(c, &cfg)
	if err != nil {
		color.Red("Error parsing configuration information, please try again latter.")
		fmt.Println(err)
		os.Exit(1)
	}

	color.Cyan("Succesfully parsed configuration information.")
	color.Cyan("We will install/run the following: ")

	for _, a := range cfg.Actions {
		a.parseArgs()
		fmt.Printf("---- Name: %s | Path: %s | Type: %s ----\n", a.Name, a.Path, a.Kind)
		if len(a.Ressources) > 0 {
			fmt.Printf("---- Ressources: %s ----\n", a.Ressources)
		}
		if len(a.Args) > 0 {
			fmt.Printf("---- Args: %s ----\n", strings.Join(a.Args, " "))
		}

		fmt.Println("-------------------------------------------------------------")
	}

	color.Cyan("Configuration successfully parsed.")

	return cfg
}

If you've read the code, you've noticed a reference to program.parseArgs(), this is a function which is used to replace:

  • ${BASEPATH} with the path to the extracted resources
  • ${RESPATH} with the path to the extracted nested resources

The code to accomplish this is simple:

func (p Program) parseArgs() {
	if p.Args != nil {
		for i, v := range p.Args {
			nv := strings.Replace(v, "${BASEPATH}", BASEPATH, -1)
			nv = strings.Replace(nv, "${RESPATH}", BASEPATH+p.Name+"\\", -1)
			p.Args[i] = nv
		}
	}
}

In our main.go file, we have the following:

//go:embed ressources
var res embed.FS

//go:embed config.yml
var config []byte

func main() {
	//This handles elevation in case we're not an administrator
	if !amAdmin() {
		runMeElevated("")
	}

	fmt.Println("Welcome to the Flex Installer.")

	//Parse our embedded configuration file
	cfg := ParseConfig(config)

	color.Cyan("Now running actions..")
	CreateTemp() //Create the temporary folder
	for _, a := range cfg.Actions {
		a.Run(&res) //Run everything
	}
	color.Cyan("Done!")
}

Now, we need to extract the files for each programs in their corresponding directories. We do this with the Program.extract function.

func (p Program) extract(fs *embed.FS) {
	fmt.Printf("Extracting %s, please wait..\n", p.Name)
	//extract main
	mainRessource, err := fs.ReadFile("ressources/" + p.Path)
	if err != nil {
		panic(err) //we panic here but should really handle the errors
	}
	createSubDir(p.Name)
	CreateFile(p.Name+"\\"+p.Path, mainRessource)

	//Now for ressources
	
    //For each ressource, extract it to it's parent action directory name
	if p.Ressources != nil {
		for _, v := range p.Ressources {
			res, err := fs.ReadFile("ressources/" + v)
			if err != nil {
				panic(err)
			}
			CreateFile(p.Name+"\\"+v, res)

		}
	}
	fmt.Printf("Successfully extracted %s.\n", p.Name)
}

And then we simply run all of this, by making some run functions.

func (p Program) Run(fs *embed.FS) {
	p.extract(fs)

	fmt.Printf("Now running %s, this may take a while..\n", p.Name)

	switch p.Kind {
	case "exe":
		p.runExe()
	case "msi":
		p.runMsi()
	case "pwsh":
		p.runPwsh()
	}

	fmt.Printf("Finished running %s. \n", p.Name)

}

func (p Program) runPwsh() {
	cmd := &exec.Cmd{
		Path: "powershell",
		Args: append([]string{
			"powershell",
			BASEPATH + p.Name + "\\" + p.Path,
		}, p.Args...),
		Stdout: os.Stdout,
	}

	if runErr := cmd.Run(); runErr != nil {
		fmt.Printf("Couldn't run %s: %s", p.Name, runErr)
	}

}

func (p Program) runExe() {
	cmd := &exec.Cmd{
		Path:   BASEPATH + p.Name + "\\" + p.Path,
		Args:   append([]string{"p.Path"}, p.Args...),
		Stdout: os.Stdout,
	}

	if runErr := cmd.Run(); runErr != nil {
		fmt.Printf("Couldn't run %s: %s", p.Name, runErr)
	}
}

func (p Program) runMsi() {
	cmd := &exec.Cmd{
		Path: "msiexec",
		//QN for NO UI /QB For basic UI
		Args:   append([]string{"msiexec", "/qn", "/i", BASEPATH + p.Name + "\\" + p.Path}, p.Args...),
		Stdout: os.Stdout,
	}

	if runErr := cmd.Run(); runErr != nil {
		fmt.Printf("Couldn't run msiexec: %s", runErr)
	}
}

VoilĂ !


To use this, simply modify the config.yaml file, dump your file in the resources directory, run go build with go 1.16 and you've got a .exe that's gonna install whatever you might want.