games/factorio: add modproxy

This adds a mod proxy system, called, well, modproxy.

It sits between Factorio server instances and the Factorio mod portal,
allowing for arbitrary mod download without needing the servers to know
Factorio credentials.

Change-Id: I7bc405a25b6f9559cae1f23295249f186761f212
diff --git a/games/factorio/modproxy/client/client.go b/games/factorio/modproxy/client/client.go
new file mode 100644
index 0000000..3e4d6ca
--- /dev/null
+++ b/games/factorio/modproxy/client/client.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"code.hackerspace.pl/hscloud/go/pki"
+	"github.com/gogo/protobuf/proto"
+	"github.com/golang/glog"
+	"google.golang.org/grpc"
+
+	"code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal"
+	pb "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagProxy        string
+	flagFactorioPath string
+	flagConfigPath   string
+)
+
+func main() {
+	flag.StringVar(&flagProxy, "proxy", "modproxy.factorio.svc.k0.hswaw.net:4200", "Address of modproxy service")
+	flag.StringVar(&flagFactorioPath, "factorio_path", "", "Path to factorio server root")
+	flag.StringVar(&flagConfigPath, "config_path", "config.pb.text", "Path to client config file")
+	flag.Parse()
+
+	conn, err := grpc.Dial(flagProxy, pki.WithClientHSPKI())
+	if err != nil {
+		glog.Exitf("Dial(%q): %v", flagProxy, err)
+		return
+	}
+
+	if flagFactorioPath == "" {
+		glog.Exitf("factorio_path must be set")
+	}
+
+	if flagConfigPath == "" {
+		glog.Exitf("config_path must be set")
+	}
+
+	configBytes, err := ioutil.ReadFile(flagConfigPath)
+	if err != nil {
+		glog.Exitf("could not read config: %v", err)
+	}
+	configString := string(configBytes)
+	config := &pb.ClientConfig{}
+	err = proto.UnmarshalText(configString, config)
+	if err != nil {
+		glog.Exitf("could not parse config: %v", err)
+	}
+
+	ctx := context.Background()
+	proxy := pb.NewModProxyClient(conn)
+
+	// mod name -> wanted mod version
+	managed := make(map[string]string)
+
+	for _, m := range config.Mod {
+		modPath := fmt.Sprintf("%s/mods/%s_%s.zip", flagFactorioPath, m.Name, m.Version)
+		_, err := os.Stat(modPath)
+		if err == nil {
+			glog.Infof("Mod %s/%s up to date, skipping.", m.Name, m.Version)
+			continue
+		}
+
+		i, err := modportal.GetMod(ctx, m.Name)
+		if err != nil {
+			glog.Errorf("Could not fetch info about %s/%s: %v", m.Name, m.Version, err)
+			continue
+		}
+
+		release := i.ReleaseByVersion(m.Version)
+		if release == nil {
+			glog.Errorf("%s/%s: version does not exist!", m.Name, m.Version)
+			continue
+		}
+
+		glog.Infof("Trying to download %s/%s (%s)...", m.Name, m.Version, release.SHA1)
+
+		err = downloadMod(ctx, proxy, m.Name, release.SHA1, modPath)
+		if err != nil {
+			glog.Errorf("%s/%s: could not download mod: %v", m.Name, m.Version, err)
+		} else {
+			glog.Infof("Mod %s/%s downloaded.", m.Name, m.Version)
+			managed[m.Name] = m.Version
+		}
+	}
+
+	glog.Infof("Cleaning up old versions of managed mods...")
+	for mn, mv := range managed {
+		modPath := fmt.Sprintf("%s/mods/%s_%s.zip", flagFactorioPath, mn, mv)
+		modGlob := fmt.Sprintf("%s/mods/%s_*.zip", flagFactorioPath, mn)
+		matches, err := filepath.Glob(modGlob)
+		if err != nil {
+			glog.Errorf("Could not find old versions of %q: %v", mn, err)
+			continue
+		}
+
+		for _, m := range matches {
+			// skip managed version
+			if m == modPath {
+				continue
+			}
+			glog.Infof("Deleting old version: %s", m)
+
+			err := os.Remove(m)
+			if err != nil {
+				glog.Errorf("Could not remove old version %q: %v", m, err)
+			}
+		}
+	}
+	glog.Infof("Done!")
+}
+
+func downloadMod(ctx context.Context, proxy pb.ModProxyClient, modName, sha1, dest string) error {
+	req := &pb.DownloadRequest{
+		ModName:  modName,
+		FileSha1: sha1,
+	}
+
+	stream, err := proxy.Download(ctx, req)
+	if err != nil {
+		return err
+	}
+
+	data := []byte{}
+
+	status := pb.DownloadResponse_STATUS_INVALID
+	for {
+		res, err := stream.Recv()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+
+		if res.Status != pb.DownloadResponse_STATUS_INVALID {
+			status = res.Status
+		}
+
+		data = append(data, res.Chunk...)
+	}
+
+	switch status {
+	case pb.DownloadResponse_STATUS_OKAY:
+	case pb.DownloadResponse_STATUS_NOT_AVAILABLE:
+		return fmt.Errorf("version not available on proxy")
+	default:
+		return fmt.Errorf("invalid download status: %v", status)
+	}
+
+	return ioutil.WriteFile(dest, data, 0644)
+}