Merge "hswaw/ldapweb: bump"
diff --git a/cluster/kube/lib/registry.libsonnet b/cluster/kube/lib/registry.libsonnet
index cb2f3f7..552d31b 100644
--- a/cluster/kube/lib/registry.libsonnet
+++ b/cluster/kube/lib/registry.libsonnet
@@ -157,6 +157,7 @@
                             { who: ["q3k", "informatic"], what: "go/svc/*" },
                             { who: ["q3k"], what: "bgpwtf/*" },
                             { who: ["q3k"], what: "devtools/*" },
+                            { who: ["q3k"], what: "games/factorio/*" },
                             { who: ["q3k", "informatic"], what: "cluster/*" },
                     ],
                     acl: [
diff --git a/games/factorio/modproxy/BUILD.bazel b/games/factorio/modproxy/BUILD.bazel
new file mode 100644
index 0000000..fb0c344
--- /dev/null
+++ b/games/factorio/modproxy/BUILD.bazel
@@ -0,0 +1,49 @@
+load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//games/factorio/modproxy/modportal:go_default_library",
+        "//games/factorio/modproxy/proto:go_default_library",
+        "//go/mirko:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//status:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "modproxy",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":modproxy",
+        "//games/factorio/modproxy/client:client",
+    ],
+    directory = "/games/factorio/modproxy/",
+)
+
+container_image(
+    name = "runtime",
+    base = "@prodimage-bionic//image",
+    layers = [
+        ":layer_bin",
+    ],
+)
+
+container_push(
+    name = "push",
+    image = ":runtime",
+    format = "Docker",
+    registry = "registry.k0.hswaw.net",
+    repository = "games/factorio/modproxy",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/games/factorio/modproxy/README.md b/games/factorio/modproxy/README.md
new file mode 100644
index 0000000..ea580c7
--- /dev/null
+++ b/games/factorio/modproxy/README.md
@@ -0,0 +1,38 @@
+Factorio modproxy
+=================
+
+The modproxy is a microservice that caches Factorio mods.
+
+Usually, Factorio mods from the mod portal need credentials in order to be downloaded. As nobody is willing to share their credentials, or buy a HSWAW factorio license, this proxy got implemented.
+
+    .-------------------.        .----------.                  .----------.
+    | mods.factorio.com |<-------| modproxy |<-------------.---|.----------.
+    '-------------------'  HTTP  '----------'     gRPC     :----| factorio |
+                                      | Local              |   '| server   |
+                                      | files              |    '----------'
+                                      V                    |    .----------.
+                                   .-'''-.                 '----| Account  |
+                                   |-___-|                      | holder   |
+                                   | CAS | Cached mods          '----------'
+                                   '-___-'
+
+Factorio servers run a `client` binary that attempts to synchronize local mods with a specified intent of wanted Factorio mods. Any mods that are missing are downloaded from the modproxy over gRPC. Regardless of the success of the downloads, the modproxy client will then continue running the Factorio server.
+
+The modproxy, when asked for a mod (via a `ModProxy.Download` gRPC call), will either serve it (if it has a copy of it), or record that this selected mod was missing and store this request in memory.
+
+Then, when an Account Holder connects and calls `ModProxy.Mirror`, the modproxy will go through its saved list of pending mods to download, and use the credentials provided to download them.
+
+Deployment
+----------
+
+Factorio servers and the modproxy live in the `factorio` namespace on k0. Factorio servers created via jsonnet will automatically spawn a modproxy client on startup that will attempt to download whatever mods havve been specified in the jsonnet configuration (which is serialized to `config.pb.text`).
+
+Synchronizing Mods as an Account Holder
+---------------------------------------
+
+If you have a Factorio account, you can connect over to the modproxy to feed it any mods that it wanted to download but couldn't. Currently this is done via a manual grpcurl call, an admin client might be introduced at some later point:
+
+    kubectl -n factorio port-forward deployment/proxy 4200
+    grpcurl -plaintext -format text -d 'username: "q3k" token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"' 127.0.0.1:4200 modproxy.ModProxy.Mirror
+
+The reuslt will be empty if no mods had to be synchronized.
diff --git a/games/factorio/modproxy/client/BUILD.bazel b/games/factorio/modproxy/client/BUILD.bazel
new file mode 100644
index 0000000..66ee10f
--- /dev/null
+++ b/games/factorio/modproxy/client/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["client.go"],
+    importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/client",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//games/factorio/modproxy/modportal:go_default_library",
+        "//games/factorio/modproxy/proto:go_default_library",
+        "//go/pki:go_default_library",
+        "@com_github_gogo_protobuf//proto:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@org_golang_google_grpc//:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "client",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
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)
+}
diff --git a/games/factorio/modproxy/main.go b/games/factorio/modproxy/main.go
new file mode 100644
index 0000000..30e41bf
--- /dev/null
+++ b/games/factorio/modproxy/main.go
@@ -0,0 +1,298 @@
+package main
+
+import (
+	"context"
+	"crypto/sha1"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	"code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal"
+	pb "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagCASDirectory string
+)
+
+func main() {
+	flag.StringVar(&flagCASDirectory, "cas_directory", "cas", "directory in which to store cached files")
+	flag.Parse()
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	srv := &service{
+		cache: make(map[string]*cacheEntry),
+	}
+
+	pb.RegisterModProxyServer(m.GRPC(), srv)
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	<-m.Done()
+}
+
+var (
+	reSha1 = regexp.MustCompile(`^[a-f0-9]+$`)
+)
+
+func casPath(sha1 string) string {
+	sha1 = strings.ToLower(sha1)
+	if !reSha1.MatchString(sha1) {
+		return ""
+	}
+	return fmt.Sprintf("%s/%s", flagCASDirectory, sha1)
+}
+
+type service struct {
+	mu sync.Mutex
+
+	// cache of sha1 -> cache entry
+	cache map[string]*cacheEntry
+}
+
+type cacheEntry struct {
+	expires *time.Time
+	modName string
+
+	// found means that this is an entry confirmed on the mod portal
+	found bool
+	// mirrored means we are ready to serve this file to users
+	mirrored bool
+}
+
+func (s *service) Mirror(ctx context.Context, req *pb.MirrorRequest) (*pb.MirrorResponse, error) {
+
+	// build map of sha1->modName for needed downloads
+	modNames := make(map[string]string)
+	s.mu.Lock()
+	for sha, e := range s.cache {
+		if e == nil {
+			continue
+		}
+		if e.found == false {
+			continue
+		}
+		if e.mirrored == true {
+			continue
+		}
+
+		modNames[sha] = e.modName
+	}
+	s.mu.Unlock()
+
+	okays := make(map[string]bool)
+	errors := make(map[string]error)
+
+	for sha, modName := range modNames {
+		k := fmt.Sprintf("%s/%s", modName, sha)
+		mod, err := modportal.GetMod(ctx, modName)
+		if err != nil {
+			errors[k] = err
+			continue
+		}
+		release := mod.ReleaseBySHA1(sha)
+		if release == nil {
+			errors[k] = fmt.Errorf("could not find sha1 in modportal - deleted?")
+			continue
+		}
+
+		r, err := release.Download(ctx, req.Username, req.Token)
+		if err != nil {
+			errors[k] = fmt.Errorf("could not download: %v", err)
+			continue
+		}
+
+		path := casPath(sha)
+		pathIncoming := path + ".incoming"
+
+		out, err := os.Create(pathIncoming)
+		if err != nil {
+			errors[k] = fmt.Errorf("could not create file: %v", err)
+			continue
+		}
+		_, err = io.Copy(out, r)
+		if err != nil {
+			errors[k] = fmt.Errorf("could not save: %v", err)
+			continue
+		}
+		err = os.Rename(pathIncoming, path)
+		if err != nil {
+			errors[k] = fmt.Errorf("could not commit file: %v", err)
+			continue
+		}
+
+		okays[k] = true
+		s.cacheFeed(sha, modName, nil, true, true)
+	}
+
+	res := &pb.MirrorResponse{
+		ModsErrors: make(map[string]string),
+	}
+	for m, _ := range okays {
+		glog.Infof("Downloaded %q", m)
+		res.ModsOkay = append(res.ModsOkay, m)
+	}
+	for m, err := range errors {
+		glog.Errorf("Could not download %q: %v", m, err)
+		res.ModsErrors[m] = fmt.Sprintf("%v", err)
+	}
+
+	return res, nil
+}
+
+func (s *service) cacheGet(sha1 string) (hit, found, mirrored bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	entry, ok := s.cache[sha1]
+	if !ok || entry == nil {
+		return
+	}
+
+	if entry.expires != nil && time.Now().Before(*entry.expires) {
+		delete(s.cache, sha1)
+		return
+	}
+
+	hit = true
+	found = entry.found
+	mirrored = entry.mirrored
+
+	return
+}
+
+func (s *service) cacheFeed(sha1, modName string, expires *time.Time, found, mirrored bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.cache[sha1] = &cacheEntry{
+		expires:  expires,
+		modName:  modName,
+		found:    found,
+		mirrored: mirrored,
+	}
+}
+
+func (s *service) serve(req *pb.DownloadRequest, srv pb.ModProxy_DownloadServer) error {
+	cas := casPath(req.FileSha1)
+	if cas == "" {
+		// Invalid sha1? Fail.
+		return status.Error(codes.Aborted, "invalid sha1")
+	}
+
+	file, err := os.Open(cas)
+	if err != nil {
+		// not in CAS, update cache and fail
+		s.cacheFeed(req.FileSha1, req.ModName, nil, true, false)
+		return srv.Send(&pb.DownloadResponse{
+			Status: pb.DownloadResponse_STATUS_NOT_AVAILABLE,
+		})
+	}
+	defer file.Close()
+
+	err = srv.Send(&pb.DownloadResponse{
+		Status: pb.DownloadResponse_STATUS_OKAY,
+	})
+	if err != nil {
+		return err
+	}
+
+	buf := make([]byte, 1024*1024)
+	hash := sha1.New()
+
+	for {
+		n, err := file.Read(buf)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return status.Errorf(codes.Unavailable, "error reading file: %v", err)
+		}
+		hash.Write(buf[:n])
+		err = srv.Send(&pb.DownloadResponse{
+			Chunk: buf[:n],
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	// entire file send, double-check shasum
+	sum := hex.EncodeToString(hash.Sum(nil))
+	if sum != req.FileSha1 {
+		glog.Errorf("CAS corruption: wanted %q, got %q", req.FileSha1, sum)
+		return status.Error(codes.Aborted, "CAS corruption")
+	}
+
+	return nil
+}
+
+func (s *service) Download(req *pb.DownloadRequest, srv pb.ModProxy_DownloadServer) error {
+	ctx := srv.Context()
+
+	modName := req.ModName
+	if modName == "" {
+		return status.Error(codes.InvalidArgument, "mod name must be set")
+	}
+	sha1 := req.FileSha1
+	if sha1 == "" {
+		return status.Error(codes.InvalidArgument, "sha1 must be set")
+	}
+	sha1 = strings.ToLower(sha1)
+	req.FileSha1 = sha1
+
+	cacheHit, found, mirrored := s.cacheGet(sha1)
+	if cacheHit {
+		if !found {
+			return status.Error(codes.NotFound, "sha1 not found for mod")
+		}
+		if !mirrored {
+			return srv.Send(&pb.DownloadResponse{
+				Status: pb.DownloadResponse_STATUS_NOT_AVAILABLE,
+			})
+		}
+
+		// we have the file, serve it
+		return s.serve(req, srv)
+	}
+
+	// cache not hit, check mod portal
+	mod, err := modportal.GetMod(ctx, modName)
+	if err != nil {
+		return err
+	}
+	release := mod.ReleaseBySHA1(sha1)
+
+	// release not found in mod portal, cache and answer
+	if release == nil {
+		expires := time.Now().Add(1 * time.Minute)
+		s.cacheFeed(sha1, modName, &expires, false, false)
+		return status.Error(codes.InvalidArgument, "sha1 not found for mod")
+	}
+
+	// we assume it's mirrored - the first cas serve will prove us wrong otherwise and
+	// update the cache.
+	s.cacheFeed(sha1, modName, nil, true, true)
+	// call ourselves again now that the cache is fed. computers - it's like magic!
+	return s.Download(req, srv)
+}
diff --git a/games/factorio/modproxy/modportal/BUILD.bazel b/games/factorio/modproxy/modportal/BUILD.bazel
new file mode 100644
index 0000000..051aec7
--- /dev/null
+++ b/games/factorio/modproxy/modportal/BUILD.bazel
@@ -0,0 +1,12 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["modportal.go"],
+    importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//status:go_default_library",
+    ],
+)
diff --git a/games/factorio/modproxy/modportal/modportal.go b/games/factorio/modproxy/modportal/modportal.go
new file mode 100644
index 0000000..5e507fd
--- /dev/null
+++ b/games/factorio/modproxy/modportal/modportal.go
@@ -0,0 +1,98 @@
+package modportal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+type Mod struct {
+	Name     string    `json:"name"`
+	Releases []Release `json:"releases"`
+}
+
+type Release struct {
+	DownloadURL string   `json:"download_url"`
+	FileName    string   `json:"file_name"`
+	Info        InfoJSON `json:"info_json"`
+	ReleasedAt  string   `json:"released_at"`
+	SHA1        string   `json:"sha1"`
+	Version     string   `json:"version"`
+}
+
+type InfoJSON struct {
+	Dependencies    []string `json:"dependencies"`
+	FactorioVersion string   `json:"factorio_json"`
+}
+
+func GetMod(ctx context.Context, name string) (*Mod, error) {
+	url := fmt.Sprintf("https://mods.factorio.com/api/mods/%s", url.PathEscape(name))
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, status.Errorf(codes.Internal, "NewRequest(%q): %v", url, err)
+	}
+
+	req = req.WithContext(ctx)
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "Do(req{%q}): %v", url, err)
+	}
+	defer res.Body.Close()
+
+	if res.StatusCode != 200 {
+		return nil, status.Errorf(codes.Unavailable, "mod portal responded with code: %d", res.StatusCode)
+	}
+
+	mod := &Mod{}
+	err = json.NewDecoder(res.Body).Decode(mod)
+	if err != nil {
+		return nil, status.Errorf(codes.Internal, "could not decode mod portal JSON: %v", err)
+	}
+
+	return mod, nil
+}
+
+func (m *Mod) ReleaseBySHA1(sha1 string) *Release {
+	for _, r := range m.Releases {
+		if r.SHA1 == sha1 {
+			return &r
+		}
+	}
+	return nil
+}
+
+func (m *Mod) ReleaseByVersion(version string) *Release {
+	for _, r := range m.Releases {
+		if r.Version == version {
+			return &r
+		}
+	}
+	return nil
+}
+
+func (r *Release) Download(ctx context.Context, username, token string) (io.ReadCloser, error) {
+	url := fmt.Sprintf("https://mods.factorio.com/%s?username=%s&token=%s", r.DownloadURL, username, token)
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, status.Errorf(codes.Internal, "NewRequest(%q): %v", url, err)
+	}
+
+	req = req.WithContext(ctx)
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "Do(req{%q}): %v", url, err)
+	}
+	if res.StatusCode != 200 {
+		res.Body.Close()
+		return nil, status.Errorf(codes.Unavailable, "mod portal responded with code: %d", res.StatusCode)
+	}
+
+	return res.Body, err
+}
diff --git a/games/factorio/modproxy/proto/BUILD.bazel b/games/factorio/modproxy/proto/BUILD.bazel
new file mode 100644
index 0000000..0c30b2c
--- /dev/null
+++ b/games/factorio/modproxy/proto/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "proto_proto",
+    srcs = ["modproxy.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "proto_go_proto",
+    compilers = ["@io_bazel_rules_go//proto:go_grpc"],
+    importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto",
+    proto = ":proto_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "go_default_library",
+    embed = [":proto_go_proto"],
+    importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/games/factorio/modproxy/proto/modproxy.proto b/games/factorio/modproxy/proto/modproxy.proto
new file mode 100644
index 0000000..a4121b2
--- /dev/null
+++ b/games/factorio/modproxy/proto/modproxy.proto
@@ -0,0 +1,43 @@
+syntax = "proto3";
+package modproxy;
+option go_package = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto";
+
+service ModProxy {
+    rpc Mirror(MirrorRequest) returns (MirrorResponse);
+    rpc Download(DownloadRequest) returns (stream DownloadResponse);
+}
+
+message MirrorRequest {
+    // Authentication details to access the mod portal.
+    string username = 1;
+    string token = 2;
+}
+
+message MirrorResponse {
+    repeated string mods_okay = 1;
+    map<string, string> mods_errors = 2;
+}
+
+message DownloadRequest {
+    string mod_name = 1;
+    string file_sha1 = 2;
+}
+
+message DownloadResponse {
+    enum Status {
+        STATUS_INVALID = 0;
+        STATUS_OKAY = 1;
+        STATUS_NOT_AVAILABLE = 2;
+    };
+    Status status = 1;
+    bytes chunk = 2;
+}
+
+// Configuration for client (in text proto, so singular names in repeated fields)
+message ClientConfig {
+    message Mod {
+        string name = 1;
+        string version = 2;
+    }
+    repeated Mod mod = 1;
+}
diff --git a/personal/q3k/factorio/kube/factorio.libsonnet b/personal/q3k/factorio/kube/factorio.libsonnet
index fc64aaa..4f2ff39 100644
--- a/personal/q3k/factorio/kube/factorio.libsonnet
+++ b/personal/q3k/factorio/kube/factorio.libsonnet
@@ -11,6 +11,7 @@
         appName: "factorio",
         storageClassName: "waw-hdd-redundant-2",
         prefix: "", # if set, should be 'foo-'
+        proxyImage: error "proxyImage must be set",
 
         rconPort: 2137,
         rconPassword: "farts",
@@ -27,6 +28,8 @@
                 memory: "1Gi",
             },
         },
+
+        mods: [],
     },
 
 
@@ -67,6 +70,16 @@
         },
     },
 
+    configMap: kube.ConfigMap(factorio.makeName("config")) {
+        metadata+: factorio.metadata,
+        data: {
+            "mods.pb.text": std.join("\n", [
+                "mod { name: \"%s\" version: \"%s\" }" % [m.name, m.version],
+                for m in cfg.mods
+            ]),
+        },
+    },
+
     deployment: kube.Deployment(factorio.makeName("factorio")) {
         metadata+: factorio.metadata,
         spec+: {
@@ -76,6 +89,23 @@
                     volumes_: {
                         data: kube.PersistentVolumeClaimVolume(factorio.volumeClaimData),
                         mods: kube.PersistentVolumeClaimVolume(factorio.volumeClaimMods),
+                        config: kube.ConfigMapVolume(factorio.configMap),
+                    },
+                    initContainers_: {
+                        modproxy: kube.Container("modproxy") {
+                            image: cfg.proxyImage,
+                            command: [
+                                "/games/factorio/modproxy/client",
+                                "-hspki_disable",
+                                "-factorio_path", "/factorio",
+                                "-proxy", "proxy.factorio.svc.cluster.local:4200",
+                                "-config_path", "/factorio/mods.pb.text",
+                            ],
+                            volumeMounts_: {
+                                mods: { mountPath: "/factorio/mods" },
+                                config: { mountPath: "/factorio/mods.pb.text", subPath: "mods.pb.text" },
+                            },
+                        },
                     },
                     containers_: {
                         factorio: kube.Container(factorio.makeName("factorio")) {
diff --git a/personal/q3k/factorio/kube/prod.jsonnet b/personal/q3k/factorio/kube/prod.jsonnet
index 7a8a060..27dd38d 100644
--- a/personal/q3k/factorio/kube/prod.jsonnet
+++ b/personal/q3k/factorio/kube/prod.jsonnet
@@ -9,15 +9,106 @@
 {
     local prod = self,
 
+    proxyImage:: "registry.k0.hswaw.net/games/factorio/modproxy:1589157915-eafe7be328477e8a6590c4210466ef12901f1b9a",
+
     namespace: kube.Namespace("factorio"),
     instance(name, tag):: factorio {
         cfg+: {
             namespace: "factorio",
             prefix: name + "-",
             tag: tag,
+            proxyImage: prod.proxyImage,
         }
     },
 
-    q3k: prod.instance("q3k", "1.0.0-1"),
-    pymods: prod.instance("pymods", "1.0.0-1"),
+    proxy: {
+        pvc: kube.PersistentVolumeClaim("proxy-cas") {
+            metadata+: {
+                namespace: "factorio",
+            },
+            spec+: {
+                storageClassName: "waw-hdd-redundant-3",
+                accessModes: [ "ReadWriteOnce" ],
+                resources: {
+                    requests: {
+                        storage: "32Gi",
+                    },
+                },
+            },
+        },
+        deploy: kube.Deployment("proxy") {
+            metadata+: {
+                namespace: "factorio",
+            },
+            spec+: {
+                template+: {
+                    spec+: {
+                        volumes_: {
+                            cas: kube.PersistentVolumeClaimVolume(prod.proxy.pvc),
+                        },
+                        containers_: {
+                            proxy: kube.Container("proxy") {
+                                image:prod.proxyImage,
+                                command: [
+                                    "/games/factorio/modproxy/modproxy",
+                                    "-hspki_disable",
+                                    "-cas_directory", "/mnt/cas",
+                                    "-listen_address", "0.0.0.0:4200",
+                                ],
+                                volumeMounts_: {
+                                    cas: { mountPath: "/mnt/cas" },
+                                },
+                                ports_: {
+                                    client: { containerPort: 4200 },
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+        },
+        svc: kube.Service("proxy") {
+            metadata+: {
+                namespace: "factorio",
+            },
+            target_pod:: prod.proxy.deploy.spec.template,
+            spec+: {
+                ports: [
+                    { name: "client", port: 4200, targetPort: 4200, protocol: "TCP" },
+                ],
+            },
+        },
+    },
+
+    local mod = function(name, version) { name: name, version: version },
+
+    q3k: prod.instance("q3k", "1.0.0-1") {
+        cfg+: {
+            mods: [
+                mod("Squeak Through", "1.8.0"),
+                mod("Bottleneck", "0.11.4"),
+            ],
+        },
+    },
+    pymods: prod.instance("pymods", "1.0.0-1") {
+        cfg+: {
+            mods: [
+                mod("Bottleneck", "0.11.4"),
+                mod("FARL", "4.0.2"),
+                mod("Squeak Through", "1.8.0"),
+                mod("pycoalprocessing", "1.8.3"),
+                mod("pycoalprocessinggraphics", "1.0.7"),
+                mod("pyfusionenergy", "1.6.3"),
+                mod("pyfusionenergygraphics", "1.0.5"),
+                mod("pyhightech", "1.6.2"),
+                mod("pyhightechgraphics", "1.0.8"),
+                mod("pyindustry", "1.4.7"),
+                mod("pyrawores", "2.1.5"),
+                mod("pyraworesgraphics", "1.0.4"),
+                mod("rso-mod", "6.0.11"),
+                mod("stdlib", "1.4.3"),
+                mod("what-is-it-really-used-for", "1.5.13"),
+            ],
+        },
+    },
 }