Compare commits

...

9 Commits

Author SHA1 Message Date
283ffb66eb rebuilt command flag system 2026-05-07 17:38:28 -06:00
3d24198ad3 Hotfix
my method of extracting archives does not preserve permission
this has be fixed with a workaround by using chmod to make factorio
executable
2026-05-04 13:11:29 -06:00
9e7bd21602 Added default config if config.yml is missing
Default config:
server:
  serverFolder: "factorio"
  worldFile: "factorio/saves/newworld.zip"
  serverSettings: "factorio/data/server-settings.json"
  serverExec: "factorio/bin/x64/factorio"
  port: 34197

factoryman:
  screen: True
  screenName: "Factorio"
  backupDir: "factorio/backups"
  username: ""
  apitoken: ""
2026-05-04 12:41:18 -06:00
e051d2d6f6 Changed GoConfig to UsrConfig
Changed GoConfig struct to UsrConfig
2026-05-04 11:50:08 -06:00
6ef8f280a8 Added README 2026-05-04 10:02:18 -06:00
cf71d956d2 Minor changes
Spelling error
modified headlessQuery string removing Sprint
2026-05-04 07:35:57 -06:00
bb65f409b4 Better way to download and extract server 2026-05-03 14:25:45 -06:00
8fb2c2aeb2 Download mods and headless server
till working on improvements to code this more like
a savepoint.
2026-05-03 14:00:16 -06:00
aecc2162e7 Added download and extract mod from modlist 2026-04-30 21:36:36 -06:00
14 changed files with 440 additions and 155 deletions

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# FactoryMan
A simple Factorio server manager for linux systems.
* Download and install headless factorio server (requires api key)
* Download and install mods directly from mod-list.json
* Start and stop factorio in a screen session, great for remote servers
* Backup saves and server
### Commands
```shell
$> ./factoryman download server
```
Run this command to download factorio-headless_linux_latest.
It will install to path in serverFolder in ``config.yml``
*NOTE: username and apitoken are required in ``config.yml``>factoryman*
---
```shell
$> ./factoryman download mod
```
Run this to download mods directly from ``$serverFolder/mods/mod-list.json``
*NOTE: username and apitoken are required in ``config.yml``>factoryman*
---
```shell
$> ./factoryman start
```
Start factorio server (in screen session by default)
---
```shell
$> ./factoryman stop
```
Stop factorio server (in screen session)
---
```shell
$> ./factoryman backup saves
```
Backup saves to path in ``config.yml``>factoryman>backupDir
---
```shell
$> ./factoryman backup full
```
Backup Full serverDir to backupDir
---
*Note:*
use ``$> screen -LS`` to view server terminal
### Simple Config
```config.yml```
```yaml
server:
serverFolder: "factorio"
worldFile: ""
serverSettings: "factorio/data/server-settings.json"
serverExec: "factorio/bin/x64/factorio"
port: 34197
factoryman:
screen: True
screenName: "Factorio"
backupDir: "factorio/backups"
username: ""
apitoken: ""
```
Default config assumes you have used factoryman to download the server

View File

@@ -7,7 +7,7 @@ import (
"time"
)
func backUp(cmd string, c GoConfig) {
func backUp(cmd string, c UsrConfig) {
switch cmd {
case "full":
fmt.Println("Starting full server backup")

58
cli.go
View File

@@ -1,58 +0,0 @@
package main
import (
"fmt"
"os"
)
func cliToolMode() {
var c = readCfg("config.yml")
modlist := string(c.Server.ServDir) + "/mods/mod-list.json"
if verifyConfig(c) {
if len(os.Args) > 1 {
switch os.Args[1] {
case "start":
startStopServer("start", c)
case "stop":
startStopServer("stop", c)
case "help", "h", "--help", "-h":
fmt.Printf("Start Server: %s start\nStop Server: %s stop\n", os.Args[0], os.Args[0])
fmt.Printf("Run backup\n\tFull backup: %s backup full", os.Args[0])
fmt.Printf("\n\tBackup saves: %s backup saves\n", os.Args[0])
case "mod":
if len(os.Args) > 2 {
switch os.Args[2] {
case "download":
if !findmodlist(modlist) {
fmt.Printf("FAILED TO FIND MOD LIST")
} else {
fmt.Printf("found %s\n", modlist)
downloadMods(modlist)
}
default:
fmt.Println("Invalid mod option: use 'update'")
}
} else {
fmt.Println("Missing mod option: use 'update'")
}
case "backup":
if len(os.Args) > 2 {
switch os.Args[2] {
case "full":
backUp("full", c)
case "saves":
backUp("saves", c)
default:
fmt.Println("Invalid backup type: use 'full' or 'saves'")
}
} else {
fmt.Println("Missing backup type: use 'full' or 'saves'")
}
default:
fmt.Printf("Unknown command: %s. Use 'start', 'stop', or 'backup'.\n", os.Args[1])
}
} else {
fmt.Println("Use 'start', 'stop', or 'backup' command\nex. factoryman start\nTo configure edit 'config.yml'")
}
}
}

View File

@@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"log"
"os"
@@ -9,68 +8,36 @@ import (
"gopkg.in/yaml.v3"
)
type GoConfig struct {
Server struct { // server specific settings
ServDir string `yaml:"serverFolder"`
WorldFile string `yaml:"worldFile"`
ServCfg string `yaml:"serverSettings"`
ServExec string `yaml:"serverExec"`
ServPort int `yaml:"port"`
} `yaml:"server"`
func DefCfg() *DefConfig {
c := &DefConfig{}
Factoryman struct { // factoryman settings
BackupDir string `yaml:"backupDir"`
UseScreen bool `yaml:"screen"`
ScreenName string `yaml:"screenName"`
UserName string `yaml:"username"`
ApiToken string `yaml:"apitoken"`
} `yaml:"factoryman"`
// server settings
c.Server.ServDir = "factorio"
c.Server.ServPort = 34197
c.Server.ServCfg = "factorio/data/server-settings.json"
c.Server.ServExec = "factorio/bin/x64/factorio"
c.Server.WorldFile = "factorio/saves/newworld.zip"
// factoryman settings
c.Factoryman.UseScreen = true
c.Factoryman.ScreenName = "factorio"
c.Factoryman.BackupDir = "factorio/backups"
c.Factoryman.UserName = ""
c.Factoryman.ApiToken = ""
return c
}
func readCfg(factCfg string) GoConfig {
func readCfg(factCfg string) UsrConfig {
//read config file (YAML)
fileBytes, err := os.ReadFile(factCfg)
if err != nil {
log.Fatalf("Error reading config file: %v", err)
fmt.Printf("Error reading config.yml file, using defaults\n")
return UsrConfig(*DefCfg())
}
// return Struct
var config GoConfig
var config UsrConfig
err = yaml.Unmarshal(fileBytes, &config)
if err != nil {
log.Fatalf("Error unmarshalling YAML file: %v", err)
}
return config
}
func isItReal(path string) bool { //check if path exists
if _, err := os.Stat(path); err == nil {
return true
} else if errors.Is(err, os.ErrNotExist) {
return false
} else {
return false
}
}
func verifyConfig(config GoConfig) bool { // check each file/dir to see if it exists
if !isItReal(config.Server.ServDir) {
fmt.Printf("PATH NOT FOUND: %s", config.Server.ServDir)
return false
}
if !isItReal(config.Server.WorldFile) {
fmt.Printf("PATH NOT FOUND: %s", config.Server.WorldFile)
return false
}
if !isItReal(config.Server.ServCfg) {
fmt.Printf("PATH NOT FOUND: %s", config.Server.ServCfg)
return false
}
if !isItReal(config.Server.ServExec) {
fmt.Printf("PATH NOT FOUND: %s", config.Server.ServExec)
return false
}
if !isItReal(config.Factoryman.BackupDir) {
fmt.Printf("PATH NOT FOUND: %s", config.Factoryman.BackupDir)
return false
}
return true
}

View File

@@ -1,6 +1,6 @@
server:
serverFolder: "factorio"
worldFile: "factorio/saves/test.zip"
worldFile: "factorio/saves/newworld.zip"
serverSettings: "factorio/data/server-settings.json"
serverExec: "factorio/bin/x64/factorio"
port: 34197

Binary file not shown.

14
go.mod
View File

@@ -1,7 +1,15 @@
module gitlab.com/Raum0x2A/factoryman
go 1.24.0
go 1.25.7
toolchain go1.24.4
require (
github.com/spf13/pflag v1.0.10
github.com/therootcompany/xz v1.0.1
gopkg.in/yaml.v3 v3.0.1
)
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)

19
go.sum
View File

@@ -1,4 +1,21 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -7,7 +7,7 @@ import (
"os/exec"
)
func startStopServer(cmd string, con GoConfig) {
func startStopServer(cmd string, con UsrConfig) {
switch cmd {
case "start":
x := fmt.Sprintf("%s --port %d --server-settings %s --start-server %s", con.Server.ServExec, con.Server.ServPort, con.Server.ServCfg, con.Server.WorldFile)

92
main.go
View File

@@ -3,12 +3,96 @@ package main
import (
"fmt"
"os"
flag "github.com/spf13/pflag"
)
func main() {
if len(os.Args) == 1 {
fmt.Println("Use 'start', 'stop', or 'backup' command\nex. factoryman start\nTo configure edit 'conifg.yml'")
} else {
cliToolMode()
var c = readCfg("config.yml")
//start command config flags
startCmd := flag.NewFlagSet("start", flag.ExitOnError)
startScreen := startCmd.BoolP("screen", "s", c.Factoryman.UseScreen, "Start server in screen session")
startServerExec := startCmd.StringP("exec", "e", c.Server.ServExec, "Path to server executable")
startSettings := startCmd.StringP("config", "c", c.Server.ServCfg, "Server config json")
startPort := startCmd.IntP("port", "p", c.Server.ServPort, "Port to start server on")
startScrName := startCmd.StringP("name", "n", c.Factoryman.ScreenName, "Name for screen session")
startWorld := startCmd.StringP("world", "w", c.Server.WorldFile, "World file")
// stop command config flags
stopCmd := flag.NewFlagSet("stop", flag.ExitOnError)
stopScrName := stopCmd.StringP("name", "n", c.Factoryman.ScreenName, "Screen session name")
// download command flags
downloadCmd := flag.NewFlagSet("download", flag.ExitOnError)
downloadSrvLoc := downloadCmd.StringP("path", "d", c.Server.ServDir, "Server Directory path")
downloadAPIKey := downloadCmd.StringP("token", "t", c.Factoryman.ApiToken, "API token")
downloadUserName := downloadCmd.StringP("username", "u", c.Factoryman.UserName, "Factorio username")
// backup command flags
backupCmd := flag.NewFlagSet("backup", flag.ExitOnError)
backupDir := backupCmd.StringP("path", "d", c.Factoryman.BackupDir, "Path to backup directory")
if len(os.Args) < 2 {
fmt.Println("Expected subcommand. \n\tex.) start, stop, download")
os.Exit(1)
}
switch os.Args[1] {
case "start":
fmt.Println("starting server")
startCmd.Parse(os.Args[2:])
//map user input settings to config struct
c.Factoryman.UseScreen = *startScreen
c.Server.ServExec = *startServerExec
c.Server.ServCfg = *startSettings
c.Server.ServPort = *startPort
c.Factoryman.ScreenName = *startScrName
c.Server.WorldFile = *startWorld
startStopServer("start", c)
case "stop":
fmt.Println("stopping server")
stopCmd.Parse(os.Args[2:])
c.Factoryman.ScreenName = *stopScrName
startStopServer("stop", c)
case "download":
downloadCmd.Parse(os.Args[2:])
c.Server.ServDir = *downloadSrvLoc
c.Factoryman.ApiToken = *downloadAPIKey
c.Factoryman.UserName = *downloadUserName
switch os.Args[2] {
case "mods":
fmt.Println("Processing mod-list.json")
downloadMods(c)
case "server":
fmt.Println("starting download")
downloadHeadless(c)
default:
fmt.Println("mods or server")
os.Exit(1)
}
case "backup":
backupCmd.Parse(os.Args[2:])
c.Factoryman.BackupDir = *backupDir
switch os.Args[2] {
case "saves":
backUp("saves", c)
case "full":
backUp("full", c)
default:
fmt.Println("saves or full")
os.Exit(1)
}
case "dev":
fmt.Println("=)")
default:
flag.Usage()
os.Exit(1)
}
}

62
mods.go
View File

@@ -3,51 +3,38 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
func findmodlist(modlist string) bool {
if !isItReal(modlist) {
return false
} else {
return true
}
}
func downloadMods(c UsrConfig) {
//var c = readCfg("config.yml")
modlist := string(c.Server.ServDir) + "/mods/mod-list.json"
func download(filedest string, url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(filedest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func downloadMods(modlist string) {
var c = readCfg("config.yml")
fmt.Printf("Updating mods from %s\n", modlist)
//read from modlist
file, err := os.Open(modlist)
if err != nil {
log.Fatalf("Error reading modlist: %v", err)
}
defer file.Close()
// create temp directory
tempDir, err := os.MkdirTemp("", "factoryman-*")
if err != nil {
log.Fatalln("Failed to create temporary directory")
}
defer os.RemoveAll(tempDir)
//decode json response from api
var modList ModList
decoder := json.NewDecoder(file)
if err := decoder.Decode(&modList); err != nil {
log.Fatalf("Error reading JSON: %v", err)
}
// Iterate over modlist.json
for _, mod := range modList.Mods {
switch mod.Name {
case "base":
@@ -59,6 +46,7 @@ func downloadMods(modlist string) {
case "space-age":
fmt.Println("Skipping space-age...")
default:
// query mod on modportal api
modportalurl := fmt.Sprintf("https://mods.factorio.com/api/mods/%s", mod.Name)
resp, err := http.Get(modportalurl)
if err != nil {
@@ -66,21 +54,29 @@ func downloadMods(modlist string) {
}
defer resp.Body.Close()
// decode json response
var moddata ModPortal
if err := json.NewDecoder(resp.Body).Decode(&moddata); err != nil {
log.Fatalf("Error reading JSON: %v", err)
}
fmt.Printf("Mod: %s, Ver: %s, Enabled: %t\n", mod.Name, mod.Version, mod.Enabled)
// query download url for mod
accessToken := fmt.Sprintf("?username=%s&token=%s", c.Factoryman.UserName, c.Factoryman.ApiToken)
modDownloadUrl := fmt.Sprintf("https://mods.factorio.com%s%s", moddata.Releases[len(moddata.Releases)-1].DownloadUrl, accessToken)
fmt.Println(modDownloadUrl)
downloadErr := download(string(c.Server.ServDir+"/mods/"+moddata.Releases[len(moddata.Releases)-1].Filename), modDownloadUrl)
fileName := string(tempDir + "/" + moddata.Releases[len(moddata.Releases)-1].Filename)
//download mod archive
downloadErr := download(fileName, modDownloadUrl)
if downloadErr != nil {
log.Fatalf("Error downloading: %v", downloadErr)
}
fmt.Printf("Downloaded: %s\n", moddata.Releases[len(moddata.Releases)-1].Filename)
fmt.Printf("Downloaded: %s\n", fileName)
fmt.Printf("extracting files to %s\n", c.Server.ServDir+"/mods/")
// extract archive to mods folder
//exec.Command("unzip", fileName, "-d", c.Server.ServDir+"/mods/")
_, extracterr := unzip(fileName, c.Server.ServDir+"/mods/")
if extracterr != nil {
log.Fatalf("Error extracting archive: %v", extracterr)
}
}
}

View File

@@ -1 +1,49 @@
package main
import (
"archive/tar"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"github.com/therootcompany/xz"
)
func downloadHeadless(c UsrConfig) {
//var c = readCfg("config.yml")
headlessQuery := "https://www.factorio.com/get-download/latest/headless/linux64"
url := headlessQuery
resp, err := http.Get(url)
if err != nil || resp.StatusCode != http.StatusOK {
log.Fatal("Download failed")
}
defer resp.Body.Close()
// Wrap stream in XZ and Tar readers
xzReader, _ := xz.NewReader(resp.Body, 0)
tr := tar.NewReader(xzReader)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
target := filepath.Join(".", header.Name)
switch header.Typeflag {
case tar.TypeDir:
os.MkdirAll(target, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(target), 0755)
outFile, _ := os.Create(target)
io.Copy(outFile, tr)
outFile.Close()
}
}
// zip does not preserve permissions correctly so i have to do this
exec.Command("chmod", "+x", c.Server.ServExec)
}

View File

@@ -1,5 +1,43 @@
package main
// config struct
type UsrConfig struct {
Server struct { // server specific settings
ServDir string `yaml:"serverFolder"`
WorldFile string `yaml:"worldFile"`
ServCfg string `yaml:"serverSettings"`
ServExec string `yaml:"serverExec"`
ServPort int `yaml:"port"`
} `yaml:"server"`
Factoryman struct { // factoryman settings
BackupDir string `yaml:"backupDir"`
UseScreen bool `yaml:"screen"`
ScreenName string `yaml:"screenName"`
UserName string `yaml:"username"`
ApiToken string `yaml:"apitoken"`
} `yaml:"factoryman"`
}
type DefConfig struct {
Server struct { // server specific settings
ServDir string
WorldFile string
ServCfg string
ServExec string
ServPort int
}
Factoryman struct { // factoryman settings
BackupDir string
UseScreen bool
ScreenName string
UserName string
ApiToken string
}
}
// JSON file from config
type Mod struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
@@ -10,6 +48,7 @@ type ModList struct {
Mods []Mod `json:"mods"`
}
// JSON response from mod poartal api
type ModPortal struct {
Category string `json:"category"`
DownloadCount int `json:"downloads_count"`

93
utils.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
//Utilities
func download(filedest string, url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(filedest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func unzip(src, dest string) ([]string, error) {
//store file names available in a array of strings
var filenames []string
r, err := zip.OpenReader(src)
if err != nil {
return filenames, err
}
defer r.Close()
for _, f := range r.File {
// Skip folders to prevent permission issues
if strings.Contains(f.Name, "__MACOSX") {
continue
}
fpath := filepath.Join(dest, f.Name)
// Checking for any invalid file paths
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return filenames, fmt.Errorf("%s is an illegal filepath", fpath)
}
filenames = append(filenames, fpath)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
// Creating the files in the target directory
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return filenames, err
}
outFile, err := os.OpenFile(fpath,
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
f.Mode())
if err != nil {
return filenames, err
}
rc, err := f.Open()
if err != nil {
return filenames, err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return filenames, err
}
}
return filenames, nil
}