Merge changes I2afe9e52,Ideb13ba9

* changes:
  app/matrix/appservice-irc: implement passwordEncryptionKey
  app/matrix/appservice-irc: add ignoreIdleUsersOnStartup option
diff --git a/.gitignore b/.gitignore
index 28383b8..3266982 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
 /bazel-*
 .kubectl
 *~
-\#*
\ No newline at end of file
+\#*
+error.log
diff --git a/README.md b/README.md
index 8c04fe4..8ba66e5 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,6 @@
 
 `hscloud` is the main monorepo of the Warsaw Hackerspace infrastructure code.
 
-Any time you see a `//path/like/this`, it refers to the root of hscloud, ie. the path `path/like/this` in this repository. Perforce and/or Bazel users should feel right at home.
-
-
-Viewing this documentation
---------------------------
-
-For a pleaseant web viewing experience, [see this documentation in hackdoc](https://hackdoc.hackerspace.pl/). This will allow you to read this markdown file (and others) in a pretty, linkable view.
-
 Getting started
 ---------------
 
diff --git a/WORKSPACE b/WORKSPACE
index 65298b1..5c987ff 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -3,7 +3,7 @@
 )
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 
 # Protobuf deps (shared between many rules).
 # Load this as early as possible, to avoid a different version being pulled in by deps of something else
@@ -25,21 +25,6 @@
     sha256 = "e46612e9bb0dae8745de6a0643be69e8665a03f63163ac6610c210e80d14c3e4",
 )
 
-# Load and setup Nixpkgs, if Nix is present on the build system.
-http_archive(
-    name = "io_tweag_rules_nixpkgs",
-    strip_prefix = "rules_nixpkgs-dc24090573d74adcf38730422941fd69b87682c7",
-    urls = ["https://github.com/tweag/rules_nixpkgs/archive/dc24090573d74adcf38730422941fd69b87682c7.tar.gz"],
-    sha256 = "aca86baa64174478c57f74ed09d5c2313113abe94aa3af030486d1b14032d3ed",
-)
-
-load("//third_party/nix:repository_rules.bzl", "hscloud_setup_nix")
-
-hscloud_setup_nix(
-    revision = "1179841f9a88b8a548f4b11d1a03aa25a790c379",
-    sha256 = "8b64041bfb9760de9e797c0a985a4830880c21732489f397e217d877edd9a990",
-)
-
 # Download Go/Gazelle rules
 http_archive(
     name = "io_bazel_rules_go",
@@ -69,9 +54,9 @@
 
 pip_repositories()
 
-load("@hscloud_pip_imports//:imports.bzl", "hscloud_pip3_import")
+load("@rules_python//python:pip.bzl", "pip3_import")
 
-hscloud_pip3_import(
+pip3_import(
     name = "pydeps",
     requirements = "//third_party/py:requirements.txt",
 )
@@ -81,12 +66,8 @@
 pip_install()
 
 # Setup Go toolchain.
-# This workspace is generated by hscloud_setup_nixpkgs. It will either call
-# go_register_toolchains() to automagically get Go toolchains from the Internet
-# or, if nix is present, instead setup a toolchain from nixpkgs.
-load("@hscloud_go_toolchain//:imports.bzl", "hscloud_go_register_toolchains")
-
-hscloud_go_register_toolchains()
+load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains")
+go_register_toolchains()
 
 # IMPORTANT: match protobuf version above with the one loaded by grpc
 http_archive(
@@ -123,9 +104,9 @@
 
 http_archive(
     name = "io_bazel_rules_docker",
-    sha256 = "dc97fccceacd4c6be14e800b2a00693d5e8d07f69ee187babfd04a80a9f8e250",
-    strip_prefix = "rules_docker-0.14.1",
-    urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.14.1/rules_docker-v0.14.1.tar.gz"],
+    sha256 = "59d5b42ac315e7eadffa944e86e90c2990110a1c8075f1cd145f487e999d22b3",
+    strip_prefix = "rules_docker-0.17.0",
+    urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.17.0/rules_docker-v0.17.0.tar.gz"],
 )
 
 load("@io_bazel_rules_docker//toolchains/docker:toolchain.bzl", docker_toolchain_configure = "toolchain_configure")
@@ -157,11 +138,11 @@
 )
 
 container_pull(
-    name = "gerrit-3.3.0",
+    name = "gerrit-3.3.2",
     registry = "index.docker.io",
     repository = "gerritcodereview/gerrit",
-    tag = "3.3.0-ubuntu20",
-    digest = "sha256:c7c43db7be19394a9a5f28a016d0063be2713144dc4bb815ceb698c895bc88d1",
+    tag = "3.3.2-ubuntu20",
+    digest = "sha256:f7fc8b749706f38475f94191117091973914a8f084e69518deff7dbc9c2c557d",
 )
 
 # third_party/factorio
@@ -185,6 +166,26 @@
 load("//devtools/gerrit/gerrit-oauth-provider:external_plugin_deps.bzl", gerrit_oauth_deps="external_plugin_deps")
 gerrit_oauth_deps(omit_commons_codec=False)
 
+# Gerrit 3.3.2 built by q3k, backported with fix for 'empty reviewers column' bug.
+# See: https://bugs.chromium.org/p/gerrit/issues/detail?id=13899
+# Override can be removed once we update to > 3.3.2, as the fix for this is
+# pending for the 3.3 branch.
+#
+# Built from v3.3.2 tag at df0507df5917fd78af01aee2495b2663530d52d1
+# Cherry-picked fix from 8731af3ae785efe9ecff7f3d04302b6b01c4fc0b
+# Resulted in commit 5949bfb86e62a32a95293e339ed86bfe52a283e9
+# Built against Java 8:
+#     bazel build --java_toolchain //tools:error_prone_warnings_toolchain :release
+#
+http_file(
+    name = "org_q3k_gerrit_3_3_2_backport",
+    urls = [
+        "https://object.ceph-waw3.hswaw.net/q3k-personal/d1839d691a8534f4ccb27bed9a98281e45972fbebec50d004cecd4d5da2b15a6.war",
+    ],
+    downloaded_file_path = "gerrit.war",
+    sha256 = "d1839d691a8534f4ccb27bed9a98281e45972fbebec50d004cecd4d5da2b15a6",
+)
+
 # minecraft spigot/bukkit deps
 # this uses rules_jvm_external vs gerrit's maven_jar because we need SNAPSHOT support
 
@@ -212,7 +213,7 @@
 
 maven_install(
     artifacts = [
-        "org.spigotmc:spigot-api:1.15.2-R0.1-SNAPSHOT",
+        "org.spigotmc:spigot-api:1.15.2-R0.1-20200624.001023-124",
         "io.grpc:grpc-netty-shaded:1.29.0",
         "io.grpc:grpc-services:1.29.0",
     ] + IO_GRPC_GRPC_JAVA_ARTIFACTS,
diff --git a/app/matrix/lib/matrix.libsonnet b/app/matrix/lib/matrix.libsonnet
index 358b0c9..168844a 100644
--- a/app/matrix/lib/matrix.libsonnet
+++ b/app/matrix/lib/matrix.libsonnet
@@ -47,8 +47,8 @@
             riot: "vectorim/riot-web:v1.7.16",
             casProxy: "registry.k0.hswaw.net/q3k/oauth2-cas-proxy:0.1.4",
             appserviceIRC: "matrixdotorg/matrix-appservice-irc:release-0.17.1",
-            # That's v0.8.2 - we just don't trust that host to not re-tag images.
-            appserviceTelegram: "dock.mau.dev/tulir/mautrix-telegram@sha256:9e68eaa80c9e4a75d9a09ec92dc4898b12d48390e01efa4de40ce882a6f7e330",
+            # :latest tag on 2021/04/10.
+            appserviceTelegram: "dock.mau.dev/tulir/mautrix-telegram@sha256:c6e25cb57e1b67027069e8dc2627338df35d156315c004a6f2b34b6aeaa79f77",
             wellKnown: "registry.k0.hswaw.net/q3k/wellknown:1611960794-adbf560851a46ad0e58b42f0daad7ef19535687c",
         },
 
diff --git a/app/matrix/matrix.0x3c.pl.jsonnet b/app/matrix/matrix.0x3c.pl.jsonnet
index ddfccba..2a9fb9a 100644
--- a/app/matrix/matrix.0x3c.pl.jsonnet
+++ b/app/matrix/matrix.0x3c.pl.jsonnet
@@ -19,7 +19,7 @@
             enable: true,
             oauth2: {
                 clientID: "YCWg1Qor9YstKn_yAHB_NT3GFAGqbnDFzIwyI_fCUWI",
-                clientSecret: (std.split(importstr "secrets/plain/cas-proxy-0x3c-oauth2-secret", "\n"))[0],
+                clientSecret: (std.split(importstr "secrets/plain/cas-proxy-0x3c-0auth2-secret", "\n"))[0],
                 scope: "read:accounts",
                 authorizeURL: "https://0x3c.pl/oauth/authorize",
                 tokenURL: "https://0x3c.pl/oauth/token",
diff --git a/bgpwtf/invoice/proto/BUILD.bazel b/bgpwtf/invoice/proto/BUILD.bazel
index 2eeae64..a1a7033 100644
--- a/bgpwtf/invoice/proto/BUILD.bazel
+++ b/bgpwtf/invoice/proto/BUILD.bazel
@@ -4,7 +4,10 @@
 
 proto_library(
     name = "proto_proto",
-    srcs = ["invoice.proto"],
+    srcs = [
+        "invoice.proto",
+        "recurrent.proto",
+    ],
     visibility = ["//visibility:public"],
 )
 
diff --git a/bgpwtf/invoice/proto/recurrent.proto b/bgpwtf/invoice/proto/recurrent.proto
new file mode 100644
index 0000000..1f4ac66
--- /dev/null
+++ b/bgpwtf/invoice/proto/recurrent.proto
@@ -0,0 +1,50 @@
+syntax = "proto3";
+package invoice;
+option go_package = "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto";
+
+import "bgpwtf/invoice/proto/invoice.proto";
+
+// Subscription is the subscription to a service for which we want to generate
+// monthly (at least for now) invoices.
+message Subscription {
+    // Template is the data that will be used to emit the invoice. It will be
+    // used verbatim in a CreateInvoice request, apart from the following
+    // changes:
+    // - if 'date' is not set, the current date will be substituted instead
+    // - for every item in the invoice, any %Y and %M value in its title will
+    //   be replaced by the year and month of the billing cycle. The billing
+    //   cycle is defined in relation to the date in the Cycle enum below..
+    InvoiceData template = 1;
+
+    // Cycle defines the billing cycle policy for this subscription.
+    enum Cycle {
+        CYCLE_INVALID = 0;
+        // The subscription is billed for the month that it is invoiced for.
+        // Eg., if the invoice has a date of April 1st, April 15th or April
+        // 30th, the %M in title will be replaced with 04.
+        //
+        // This is used for subscriptions that are invoiced a month in advance,
+        // with invoices being sent out in the beginning of the month.
+        //
+        // In the future, the meaning of this enum value might change to 'bill
+        // at beginning of month/cycle', but currently we only bill once per
+        // month.
+        CYCLE_CURRENT = 1;
+        // The subscription is billed for the month from when it was invoiced.
+        // Eg., if the invoice has a date of April 1st, April 15th or April
+        // 30th, the %M in the title will be replaced with 03.
+        // This is used for subscriptions that are invoiced right after a month
+        // ends.
+        // In the future, the meaning of this enum value might change to 'bill
+        // at end of month/cycle', but currently we only bill once per month.
+        CYCLE_PREV = 2;
+    }
+    Cycle cycle = 2;
+}
+
+// Configuration is a prototext defining subscriptions. Currently it's read
+// from a file by //bgpwtf/invoice/recurrent. In The future this might be
+// broken up into a database schema.
+message Configuration {
+    repeated Subscription subscription = 1;
+}
diff --git a/bgpwtf/invoice/recurrent/BUILD.bazel b/bgpwtf/invoice/recurrent/BUILD.bazel
new file mode 100644
index 0000000..b9fc578
--- /dev/null
+++ b/bgpwtf/invoice/recurrent/BUILD.bazel
@@ -0,0 +1,21 @@
+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/bgpwtf/invoice/recurrent",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//bgpwtf/invoice/proto:go_default_library",
+        "//go/pki:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_golang_protobuf//proto:go_default_library",
+        "@org_golang_google_grpc//:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "recurrent",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/invoice/recurrent/main.go b/bgpwtf/invoice/recurrent/main.go
new file mode 100644
index 0000000..84e5622
--- /dev/null
+++ b/bgpwtf/invoice/recurrent/main.go
@@ -0,0 +1,175 @@
+package main
+
+// recurrent is a tool to bill recurrent monthly invoices. It should be run at
+// the beginning of each month against a database of customers stored as a
+// prototext.
+//
+// This is a fairly janky tool, and should be replaced by a proper billing
+// service.
+//
+//    $ bazel run //bgpwtf/invoice/recurrent -- \
+//        -invoice_configuration=$(pwd)bgpwtf/invoice/customers.pb.text \
+//        -invoice_service 10.78.253.10:4200 -hspki_disable
+//
+// q3k has the sqlite database for the invoice service and the customer
+// prototext.
+
+import (
+	"bufio"
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/golang/glog"
+	"github.com/golang/protobuf/proto"
+	"google.golang.org/grpc"
+
+	"code.hackerspace.pl/hscloud/go/pki"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagConfiguration string
+	flagService       string
+)
+
+func main() {
+	flag.StringVar(&flagService, "invoice_service", "127.0.0.1:4200", "Address of invoice service")
+	flag.StringVar(&flagConfiguration, "invoice_configuration", "customers.pb.text", "Prototext of customer data")
+	flag.Parse()
+
+	if flagConfiguration == "" {
+		glog.Exit("-invoice_configuration must be set")
+	}
+	cfgBytes, err := ioutil.ReadFile(flagConfiguration)
+	if err != nil {
+		glog.Exitf("could not read configuration: %v", err)
+	}
+
+	var cfg pb.Configuration
+	if err := proto.UnmarshalText(string(cfgBytes), &cfg); err != nil {
+		glog.Exitf("UnmarshalText: %v", err)
+	}
+
+	conn, err := grpc.Dial(flagService, pki.WithClientHSPKI())
+	if err != nil {
+		glog.Exitf("Dial(%q): %v", flagService, err)
+		return
+	}
+	svc := pb.NewInvoicerClient(conn)
+	ctx := context.Background()
+
+	var created []string
+	now := time.Now()
+	for _, sub := range cfg.Subscription {
+		glog.Infof("Emitting for %q...", sub.Template.CustomerBilling[0])
+
+		data := sub.Template
+		if data.Date == 0 {
+			data.Date = now.UnixNano()
+		}
+
+		date := time.Unix(0, data.Date)
+		year := int(date.Year())
+		month := int(date.Month())
+		switch sub.Cycle {
+		case pb.Subscription_CYCLE_CURRENT:
+		case pb.Subscription_CYCLE_PREV:
+			month -= 1
+			if month < 1 {
+				month = 12
+				year -= 1
+			}
+		default:
+			glog.Exitf("Invalid cycle: %v", sub.Cycle)
+		}
+
+		for _, item := range data.Item {
+			item.Title = strings.ReplaceAll(item.Title, "%M", fmt.Sprintf("%02d", month))
+			item.Title = strings.ReplaceAll(item.Title, "%Y", fmt.Sprintf("%04d", year))
+		}
+		res, err := svc.CreateInvoice(ctx, &pb.CreateInvoiceRequest{
+			InvoiceData: data,
+		})
+		if err != nil {
+			glog.Exitf("CreateInvoice: %v", err)
+		}
+		glog.Infof("Created invoice %q", res.Uid)
+		created = append(created, res.Uid)
+	}
+
+	reader := bufio.NewReader(os.Stdin)
+	fmt.Print("Invoices generated. Seal? [Yn]")
+	text, err := reader.ReadString('\n')
+	if err != nil {
+		glog.Exitf("Response: %v", err)
+	}
+	switch strings.TrimSpace(strings.ToLower(text)) {
+	case "", "y":
+	default:
+		glog.Exitf("Aborting.")
+	}
+	for _, uid := range created {
+		glog.Infof("Sealing %q...", uid)
+		_, err := svc.SealInvoice(ctx, &pb.SealInvoiceRequest{
+			Uid:        uid,
+			DateSource: pb.SealInvoiceRequest_DATE_SOURCE_PROFORMA,
+			Language:   "pl",
+		})
+		if err != nil {
+			glog.Errorf("Sealing %q failed: %v", uid, err)
+			continue
+		}
+		res, err := svc.GetInvoice(ctx, &pb.GetInvoiceRequest{
+			Uid: uid,
+		})
+		if err != nil {
+			glog.Errorf("Retrieving sealed invoice %q failed: %v", uid, err)
+			continue
+		}
+		fuid := res.Invoice.FinalUid
+		glog.Infof("%q: Final UID: %s", uid, fuid)
+		stream, err := svc.RenderInvoice(ctx, &pb.RenderInvoiceRequest{
+			Uid: uid,
+		})
+		if err != nil {
+			glog.Errorf("Rendering sealed invoice failed: %v", err)
+			continue
+		}
+
+		path := fmt.Sprintf("/tmp/%s.pdf", strings.ReplaceAll(fuid, "/", ""))
+		glog.Infof("Downloading %s...", path)
+		f, err := os.Create(path)
+		if err != nil {
+			glog.Errorf("Create: %v", err)
+			continue
+		}
+
+		for {
+			block, err := stream.Recv()
+			if err == io.EOF {
+				break
+			}
+			if err != nil {
+				glog.Errorf("Recv: %v", err)
+				break
+			}
+			if _, err := f.Write(block.Data); err != nil {
+				glog.Errorf("Write: %v", err)
+				break
+			}
+		}
+		f.Close()
+	}
+
+}
diff --git a/bgpwtf/invoice/render.go b/bgpwtf/invoice/render.go
index 693aa62..db5c151 100644
--- a/bgpwtf/invoice/render.go
+++ b/bgpwtf/invoice/render.go
@@ -4,6 +4,7 @@
 	"bytes"
 	"fmt"
 	"html/template"
+	"strings"
 	"time"
 
 	wkhtml "github.com/sebastiaanklippert/go-wkhtmltopdf"
@@ -41,6 +42,24 @@
 		Total     string
 	}
 
+	symbols := ""
+	var parts []string
+	for _, code := range i.Data.SpCode {
+		parts = append(parts, code.String())
+	}
+	for _, code := range i.GtuCode {
+		parts = append(parts, code.String())
+	}
+	if len(parts) > 0 {
+		symbols = strings.Join(parts, ", ") + "."
+	} else {
+		if language == "en" {
+			symbols = "<i>brak (none)</i>."
+		} else {
+			symbols = "<i>brak</i>."
+		}
+	}
+
 	data := struct {
 		InvoiceNumber         string
 		InvoicerBilling       []string
@@ -60,6 +79,7 @@
 		VATTotal              string
 		Total                 string
 		DeliveryCharge        string
+		Symbols               template.HTML
 	}{
 		InvoiceNumber:         i.FinalUid,
 		Date:                  time.Unix(0, i.Date),
@@ -75,6 +95,7 @@
 
 		InvoicerBilling: make([]string, len(i.Data.InvoicerBilling)),
 		InvoiceeBilling: make([]string, len(i.Data.CustomerBilling)),
+		Symbols:         template.HTML(symbols),
 	}
 
 	for d, s := range i.Data.InvoicerBilling {
diff --git a/bgpwtf/invoice/templates/invoice_en.html b/bgpwtf/invoice/templates/invoice_en.html
index 6a92022..d4aa3fc 100644
--- a/bgpwtf/invoice/templates/invoice_en.html
+++ b/bgpwtf/invoice/templates/invoice_en.html
@@ -92,6 +92,9 @@
     -webkit-transform: rotate(-45deg);
     text-transform: uppercase;
 }
+div.symbols {
+    padding: 0 0 0.5rem 0.1rem;
+}
         </style>
     </head>
     <body>
@@ -140,19 +143,22 @@
                     {{ end }}
                     {{ end }}
                     {{ if .USCustomer }}
-                    <li>EIN: {{ .InvoiceeVAT }}</li>
-                    <li><b>(VAT zero rate)</b></li>
+                    <li>{{ .InvoiceeVAT }}</li>
+                    <li style="margin-top: 2em;"><b>VAT NP</b> <i>(zero rate)</i></li>
                     {{ else if .InvoiceeVAT }}
                     <li><b>NIP:</b> {{ .InvoiceeVAT }}</li>
                     {{ end }}
 
                     {{ if .ReverseVAT }}
-                    <li><b>(reverse charge / obciążenie odwrotne)</b></li>
+                    <li><b>Obciążenie odwrotne</b> <i>(reverse charge)</ii></li>
                     {{ end }}
                 </ul>
             </div>
         </div>
         <div style="clear: both; height: 1em;"></div>
+        <div class="symbols">
+            <b>Oznaczenia</b> <i>(JPK symbols)</i>: {{ .Symbols }}
+        </div>
         <table class="items">
             <tr>
                 <th style="width: 60%;">Name of goods / service</th>
diff --git a/bgpwtf/invoice/templates/invoice_pl.html b/bgpwtf/invoice/templates/invoice_pl.html
index 15f5d08..b09086b 100644
--- a/bgpwtf/invoice/templates/invoice_pl.html
+++ b/bgpwtf/invoice/templates/invoice_pl.html
@@ -92,6 +92,9 @@
     -webkit-transform: rotate(-45deg);
     text-transform: uppercase;
 }
+div.symbols {
+    padding: 0 0 0.5rem 0.1rem;
+}
         </style>
     </head>
     <body>
@@ -154,6 +157,9 @@
             </div>
         </div>
         <div style="clear: both; height: 1em;"></div>
+        <div class="symbols">
+            <b>Oznaczenia</b>: {{ .Symbols }}
+        </div>
         <table class="items">
             <tr>
                 <th style="width: 60%;">Nazwa towaru lub usługi</th>
diff --git a/bgpwtf/machines/modules/bootstrap.nix b/bgpwtf/machines/modules/bootstrap.nix
index 09f2555..120bf8c 100644
--- a/bgpwtf/machines/modules/bootstrap.nix
+++ b/bgpwtf/machines/modules/bootstrap.nix
@@ -1,12 +1,35 @@
-# Functionality that used to live in bootstrap.hswaw.net, a VM.
-# PXE boot support has been removed and the functionality moved back to
-# edge01.waw.bgp.wtf.
+# Functionality that used to live in bootstrap.hswaw.net, a VMm now moved back
+# into edge01.waw.bgp.wtf
 
 { config, pkgs, ... }: {
   networking.bridges.bootstrap.interfaces = [];
   networking.interfaces.bootstrap.ipv4.addresses = [
     { address = "185.236.240.18"; prefixLength = 32; }
   ];
+  services.atftpd = {
+    enable = true;
+    root = (let 
+      # netboot.xyz.kpxe retrieved from netboot.xyz at 2021/03/15.
+      netbootxyz-kpxe = pkgs.fetchurl {
+        url = https://object.ceph-waw3.hswaw.net/q3k-personal/b0b99ab84eb973c8a3818ec9fd11e17b75494ad8b5e6976aebbe8b162052da47.kpxe;
+        sha256 = "0iysa8h1d2xyxdm9grmmv154jxbvw48zvjcfh6iwhwxr9sw9mfdh";
+      };
+      # netboot.xyz-undionly.kpxe retrieved from netboot.xyz at 2021/03/15.
+      netbootxyz-undionly-kpxe = pkgs.fetchurl {
+        url = https://object.ceph-waw3.hswaw.net/q3k-personal/42ca97fe5219899e208cf466cb04d2cd4fe7f7b30e0db30ef63cafabcc95b989.kpxe;
+        sha256 = "12drjp6apbrwyq7b638fngvyfkyds82cnrplihh9x28rabz9gjj2";
+      };
+    in pkgs.stdenv.mkDerivation {
+      name = "atftp-root";
+      unpackPhase = "true";
+      buildPhase = "true";
+      installPhase = ''
+        mkdir -p $out
+        cp ${netbootxyz-kpxe} $out/netboot.xyz.kpxe
+        cp ${netbootxyz-undionly-kpxe} $out/netboot.xyz-undionly.kpxe
+      '';
+    });
+  };
   services.dhcpd4 = {
     enable = true;
     interfaces = [ "bootstrap" "vl-dcsw-l3" ];
@@ -43,29 +66,28 @@
           hardware ethernet 00:23:ae:fe:42:80;
           fixed-address 185.236.240.37;
           option host-name "bc01n03";
+          filename "netboot.xyz-undionly.kpxe";
+      }
+      host bc01n04 {
+          hardware ethernet 00:23:ae:fe:7e:a8;
+          fixed-address 185.236.240.41;
+          option host-name "bc01n04";
+          filename "netboot.xyz-undionly.kpxe";
       }
       host boston-packets {
           hardware ethernet 00:23:ae:fe:45:8c;
           fixed-address 185.236.240.38;
           option host-name "boston-packets.hackerspace.pl";
-          #filename "ipxe.efi";
       }
       host dcr01s22 {
           hardware ethernet 90:1b:0e:08:12:b8;
           fixed-address 185.236.240.39;
           option host-name "dcr01s22";
-          #filename "ipxe.efi";
       }
       host dcr01s24 {
           hardware ethernet 90:1b:0e:31:bb:6a;
           fixed-address 185.236.240.40;
           option host-name "dcr01s24";
-          #filename "ipxe.efi";
-      }
-      host dsctf {
-          hardware ethernet 00:23:ae:fe:45:50;
-          fixed-address 185.236.240.41;
-          option host-name "dsctf";
       }
       host dcr03s32b1 {
           hardware ethernet 02:01:87:4a:9a:b9;
diff --git a/bgpwtf/machines/modules/eoip.nix b/bgpwtf/machines/modules/eoip.nix
deleted file mode 100644
index 5ce04f3..0000000
--- a/bgpwtf/machines/modules/eoip.nix
+++ /dev/null
@@ -1,75 +0,0 @@
-# A small Ethernet-over-IP service implementation.
-# Yes, that's the Mikrotik EoIP implementation. This one is somewhat sketchy
-# (notably, it pumps huge zero-padded frames into tap), so doesn't use it for
-# production. We currently only use it in the edge01.waw test framework to
-# bring vlans across test VMs.
-
-{ config, pkgs, lib, ... }:
-
-with lib;
-
-let
-  eoip = pkgs.stdenv.mkDerivation {
-    pname = "eoip";
-    version = "20180119";
-    nativeBuildInputs = with pkgs; [ cmake ];
-    src = pkgs.fetchFromGitHub {
-      owner = "amphineko";
-      repo = "eoiptapd";
-      rev = "5573a905bcbc001b503308665f098e82f451dc33";
-      sha256 = "0np9dzcw5w6jarzdv2yh3mbzz0wgw10sjqyi6pxan4ipr75v1b8s";
-    };
-    installPhase = ''
-      mkdir -p $out/bin
-      cp eoiptapd $out/bin/eoiptapd
-    '';
-  };
-
-  cfg = config.hscloud.eoip;
-
-in {
-  options.hscloud.eoip = {
-    interfaces = mkOption {
-      type = with types; attrsOf (submodule {
-        options = {
-          localV4 = mkOption {
-            type = types.str;
-            description = "Local outer IPv4 address";
-          };
-          remoteV4 = mkOption {
-            type = types.str;
-            description = "Remote outer IPv4 address";
-          };
-          id = mkOption {
-            type = types.int;
-            description = "Tunnel ID";
-          };
-          parent = mkOption {
-            type = types.str;
-            description = "Parent/outer device";
-          };
-        };
-      });
-      description = ''
-        EoIP interfaces to create.
-      '';
-    };
-  };
-
-  config.systemd.services = mapAttrs' (name: value: nameValuePair "${name}-eoip" {
-    wantedBy = [ "network.target" ];
-    wants = [
-      "${name}-netdev.service"
-      "network-addresses-${value.parent}.service"
-    ];
-    after = [
-      "network-addresses-${value.parent}.service"
-    ];
-    serviceConfig = {
-      Type = "simple";
-      ExecStart = "${eoip}/bin/eoiptapd -i ${name} -l ${value.localV4} -r ${value.remoteV4} -t ${toString value.id}";
-      Restart = "always";
-      RestartSec = "1";
-    };
-  }) cfg.interfaces;
-}
diff --git a/bgpwtf/machines/modules/gretap.nix b/bgpwtf/machines/modules/gretap.nix
new file mode 100644
index 0000000..7d3d847
--- /dev/null
+++ b/bgpwtf/machines/modules/gretap.nix
@@ -0,0 +1,62 @@
+# Support for GRETap interfaces in NixOS' scripted networking.
+#
+# We currently only use it in the edge01.waw test framework to bring vlans
+# across test VMs.
+
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.hscloud.gretap;
+
+in {
+  options.hscloud.gretap = {
+    interfaces = mkOption {
+      type = with types; attrsOf (submodule {
+        options = {
+          localV4 = mkOption {
+            type = types.str;
+            description = "Local outer IPv4 address";
+          };
+          remoteV4 = mkOption {
+            type = types.str;
+            description = "Remote outer IPv4 address";
+          };
+          id = mkOption {
+            type = types.int;
+            description = "Tunnel ID";
+          };
+          parent = mkOption {
+            type = types.str;
+            description = "Parent/outer device";
+          };
+        };
+      });
+      description = ''
+        GRETap interfaces to create.
+      '';
+    };
+  };
+
+  config.boot.kernelModules = [ "fou" ];
+  config.systemd.services = mapAttrs' (name: value: nameValuePair "${name}-gretap" {
+    wants = [
+      "${name}-netdev.service"
+      "network-addresses-${value.parent}.service"
+    ];
+    after = [
+      "network-addresses-${value.parent}.service"
+    ];
+    before = [
+      "network-addresses-${name}.service"
+    ];
+    wantedBy = [
+      "network-addresses-${name}.service"
+    ];
+    serviceConfig = {
+      Type = "oneshot";
+      ExecStart = "${pkgs.iproute2}/bin/ip link add name ${name} type gretap remote ${value.remoteV4} local ${value.localV4} key ${toString value.id}";
+    };
+  }) cfg.interfaces;
+}
diff --git a/bgpwtf/machines/modules/router.nix b/bgpwtf/machines/modules/router.nix
index a9de59b..88ad004 100644
--- a/bgpwtf/machines/modules/router.nix
+++ b/bgpwtf/machines/modules/router.nix
@@ -48,11 +48,11 @@
   # Enable the OpenSSH daemon.
   services.openssh.enable = true;
   users.users.root.openssh.authorizedKeys.keys = [
-    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDD4VJXAXEHEXZk2dxNwehneuJcEGkfXG/U7z4fO79vDVIENdedtXQUyLyhZJc5RTEfHhQj66FwIqzl7mzBHd9x9PuDp6QAYXrkVNMj48s6JXqZqBvF6H/weRqFMf4a2TZv+hG8D0kpvmLheCwWAVRls7Jofnp/My+yDd57GMdsbG/yFEf6WPMiOnA7hxdSJSVihCsCSw2p8PD4GhBe8CVt7xIuinhutjm9zYBjV78NT8acjDUfJh0B1ODTjs7nuW1CC4jybSe2j/OU3Yczj4AxRxBNWuFxUq+jBo9BfpbKLh+Tt7re+zBkaicM77KM/oV6943JJxgHNBBOsv9scZE7 q3k@amnesia"
     "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG599UildOrAq+LIOQjKqtGMwjgjIxozI1jtQQRKHtCP q3k@mimeomia"
     "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQb3YQoiYFZLKwvHYKbu1bMqzNeDCAszQhAe1+QI5SLDOotclyY/vFmOReZOsmyMFl71G2d7d+FbYNusUnNNjTxRYQ021tVc+RkMdLJaORRURmQfEFEKbai6QSFTwErXzuoIzyEPK0lbsQuGgqT9WaVnRzHJ2Q/4+qQbxAS34PuR5NqEkmn4G6LMo3OyJ5mwPkCj9lsqz4BcxRaMWFO3mNcwGDfSW+sqgc3E8N6LKrTpZq3ke7xacpQmcG5DU9VO+2QVPdltl9jWbs3gXjmF92YRNOuKPVfAOZBBsp8JOznfx8s9wDgs7RwPmDpjIAJEyoABqW5hlXfqRbTnfnMvuR informatic@InformaticPC"
     "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGkMgEVwQM8yeuFUYL2TwlJIq9yUNBmHnwce46zeL2PK2CkMz7sxT/om7sp/K5XDiqeD05Nioe+Dr3drP6B8uI33S5NgxPIfaqQsRS+CBEgk6cqFlcdlKETU/DT+/WsdoO173n7mgGeafPInEuQuGDUID0Fl099kIxtqfAhdeZFMM6/szAZEZsElLJ8K6dp1Ni/jmnXCZhjivZH3AZUlnqrmtDG7FY1bgcOfDXAal45LItughGPtrdiigXe9DK2fW3+9DBZZduh5DMJTNlphAZ+nfSrbyHVKUg6WsgMSprur4KdU47q1QwzqqvEj75JcdP1jOWoZi4F6VJDte9Wb9lhD1jGgjxY9O6Gs4CH35bx15W7CN9hgNa0C8NbPJe/fZYIeMZmJ1m7O2xmnYwP8j+t7RNJWu7Pa3Em4mOEXvhBF07Zfq+Ye/4SluoRgADy5eII2x5fFo5EBhInxK0/X8wF6XZvysalVifoCh7T4Edejoi91oAxFgYAxbboXGlod0eEHIi2hla8SM9+IBHOChmgawKBYp2kzAJyAmHNBF+Pah9G4arVCj/axp/SJZDZbJQoI7UT/fJzEtvlb5RWrHXRq+y6IvjpUq4pzpDWW04+9UMqEEXRmhWOakHfEVM9rN8h3aJBflLUBBnh0Z/hVsKNh8bCRHaKtah8TrD9i+wMw== patryk.jakuszew@gmail.com"
     "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC33naG1ptCvUcRWX9cj9wXM1nW1lyQC4SvMJzWlr9aMD96O8hQ2JMkuIUgUJvorAY02QRplQ2BuoVoVkdkzwjMyi1bL3OdgcKo7Z1yByClGTTocqNJYY0lcUb6EJH8+6e6F9ydrQlSxNzL1uCaA7phZr+yPcmAmWbSfioXn98yXNkE0emHxzJv/nypJY56sDCMC2IXDRd8L2goDtPwgPEW7bWfAQdIFMJ75xOidZOTxJ8eqyXLw/kxY5UlyX66jdoYz1sE5XUHuoQl1AOG9UdlMo0aMhUvP4pX5l7r7EnA9OttKMFB3oWqkVK/R6ynZ52YNOU5BZ9V+Ppaj34W0xNu+p0mbHcCtXYCTrf/OU0hcZDbDaNTjs6Vtcm2wYw9iAKX7Tex+eOMwUwlrlcyPNRV5BTot7lGNYfauHCSIuWJKN4NhCLR/NtVNh4/94eKkPTwJsY6XqDcS7q49wPAs4DAH7BJgsbHPOqygVHrY0YYEfz3Pj0HTxJHQMCP/hQX4fXEGt0BjgoVJbXPAQtPyeg0JuxiUg+b4CgVVfQ6R060MlM1BZzhmh+FY5MJH6nJppS0aHYCvSg8Z68NUlCPKy0jpcyfuAIWQWwSGG1O010WShQG2ELsvNdg5/4HVdCGNl5mmoom6JOd72FOZyQlHDFfeQUQRn9HOeCq/c51rK99SQ== bartek@IHM"
     "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICTR292kx/2CNuWYIsZ6gykQ036aBGrmheIuZa6S1D2x implr@thonk"
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfIRe1nH6vwjQTjqHNnkKAdr1VYqGEeQnqInmf3A6UN ar@khas"
   ];
 }
diff --git a/bgpwtf/machines/tests/edge01-waw.nix b/bgpwtf/machines/tests/edge01-waw.nix
index 535418f..1d724e1 100644
--- a/bgpwtf/machines/tests/edge01-waw.nix
+++ b/bgpwtf/machines/tests/edge01-waw.nix
@@ -5,9 +5,9 @@
 # - bgpspeaker, which simulates bgp upstreams
 # - customs, which simulates customs.hackerspace.pl.
 #
-# We use EoIP to build up virtual ethernet links between the machines, and
-# to run VLANs on that. We don't just use plain 'vlans' from NixOS tests as
-# we actually want to run 802.1q ourselves from the edge01 config.
+# We use GRETap to build up virtual ethernet links between the machines, and to
+# run VLANs on that. We don't just use plain 'vlans' from NixOS tests as we
+# actually want to run 802.1q ourselves from the edge01 config.
 #
 # Everything else is pretty much straightforward. Bring up everything, ping
 # stuff. We don't really test much else than internet routing.
@@ -31,19 +31,15 @@
   virtualisation.memorySize = 1024;
   virtualisation.vlans = [ 1 ];
   imports = [
-    ../modules/eoip.nix
+    ../modules/gretap.nix
   ];
 
-  hscloud.eoip.interfaces."nnet" = {
+  hscloud.gretap.interfaces."nnet" = {
     parent = "eth1";
     localV4 = "192.168.1.3";
     remoteV4 = "192.168.1.2";
     id = 100;
   };
-  networking.interfaces."nnet" = {
-    virtual = true;
-    virtualType = "tap";
-  };
   networking.vlans = {
     "vl-globalmix" = { interface = "nnet"; id = 466; };
   };
@@ -142,27 +138,20 @@
     dut = { config, pkgs, ... }: {
       imports = [
         ../edge01.waw.bgp.wtf.nix
-        ../modules/eoip.nix
+        ../modules/gretap.nix
       ];
       virtualisation.memorySize = 1024;
       virtualisation.vlans = [
         1 2
       ];
 
-      hscloud.eoip.interfaces = {
+      hscloud.gretap.interfaces = {
         "e1-nnet" = { parent = "eth1"; localV4 = "192.168.1.2"; remoteV4 = "192.168.1.3"; id = 100; };
         "e2-customs" = { parent = "eth2"; localV4 = "192.168.2.2"; remoteV4 = "192.168.2.1"; id = 200; };
         "e3-mgmt" = { parent = "eth2"; localV4 = "192.168.2.2"; remoteV4 = "192.168.2.111"; id = 300; }; # not connected
         "e4-oob" = { parent = "eth2"; localV4 = "192.168.2.2"; remoteV4 = "192.168.2.112"; id = 400; }; # not connected
         "e7-dcsw" = { parent = "eth2"; localV4 = "192.168.2.2"; remoteV4 = "192.168.2.113"; id = 500; }; # not connected
       };
-      networking.interfaces = {
-        "e1-nnet" = { virtual = true; virtualType = "tap"; };
-        "e2-customs" = { virtual = true; virtualType = "tap"; };
-        "e3-mgmt" = { virtual = true; virtualType = "tap"; };
-        "e4-oob" = { virtual = true; virtualType = "tap"; };
-        "e7-dcsw" = { virtual = true; virtualType = "tap"; };
-      };
       hscloud.anchorvm = {
         blkdev = "/anchor.img";
         ram = 32;
@@ -180,7 +169,7 @@
 
     customs = { config, pkgs, ... }: {
       imports = [
-        ../modules/eoip.nix
+        ../modules/gretap.nix
       ];
       environment.systemPackages = with pkgs; [
         tcpdump htop dstat file dhcpcd
@@ -194,12 +183,10 @@
       networking.defaultGateway = "185.236.240.4";
       networking.defaultGateway6 = "2a0d:eb00:2137:1::2";
       networking.interfaces."edge" = {
-        virtual = true;
-        virtualType = "tap";
         ipv4.addresses = [{ address = "185.236.240.5"; prefixLength = 31; }];
         ipv6.addresses = [{ address = "2a0d:eb00:2137:1::3"; prefixLength = 127; }];
       };
-      hscloud.eoip.interfaces."edge" = {
+      hscloud.gretap.interfaces."edge" = {
         parent = "eth2";
         localV4 = "192.168.2.1";
         remoteV4 = "192.168.2.2";
diff --git a/cluster/README.md b/cluster/README.md
new file mode 100644
index 0000000..40416b9
--- /dev/null
+++ b/cluster/README.md
@@ -0,0 +1,7 @@
+Cluster Docs Home
+=================
+
+Documentation relating to our Kubernetes cluster(s).
+
+For information about the physical DC infrastructure, see [//dc](/dc/).
+
diff --git a/cluster/certs/etcd-bc01n01.hswaw.net.cert b/cluster/certs/etcd-bc01n01.hswaw.net.cert
index 917ad22..d16a7d7 100644
--- a/cluster/certs/etcd-bc01n01.hswaw.net.cert
+++ b/cluster/certs/etcd-bc01n01.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFHDCCBASgAwIBAgIUBd/lkgCFa6VNTU862aFQGQd6i8gwDQYJKoZIhvcNAQEL
+MIIFHDCCBASgAwIBAgIUKFAZXQEwMakUQQEyk/RkCzXhCMcwDQYJKoZIhvcNAQEL
 BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNTUzMDBaFw0y
-MTAzMjgxNTUzMDBaMHcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
+Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMTAzMjcxMTI3MDBaFw0y
+MjAzMjcxMTI3MDBaMHcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
 ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxub2RlIGV0Y2Qgc2VydmVyIGNl
 cnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAxLmhzd2F3Lm5ldDCCAiIwDQYJKoZI
 hvcNAQEBBQADggIPADCCAgoCggIBAKkSjQ1EVgGR6w/MTgdnd4GSTqOOFgp/cTpk
@@ -21,10 +21,10 @@
 AQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTKhiQ/ZeBG
 JuOq1RG7YfHouV2kSTAfBgNVHSMEGDAWgBTxWbhmTT7D50LugaPURn85U3X5DzAc
 BgNVHREEFTATghFiYzAxbjAxLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOCAQEA
-LzWMoHGuMxBJaql7y6PrlGr2cKX/3vBED1x4kIe1RVAA0JyF0lNpKf3dmWvHHWkF
-x7Op8/B0kKlhQAsjY2f2DvYTw+d9tg3Kg2OkS8xuBxFmMJupOQxSApp+Gi4k92kM
-SdgLIrtey4eQ1mFtWhssFWOKrU3NOXD1iLl+BfEqwvlhm524HTPlqKocBkAUCeFe
-gdei5U6FwlU/l7vhqm7Qr4doOblr63/2ls9/cOv14tweovPLtSJaYDbtE/Dto7RT
-khhK/MS0n19n1+aAXWTlcYU/0kHagaVFIRlvVyp6nMFhLV+T21jTrnf98q4mdybC
-+lUKqLwE5y4V7f/FWIKfhg==
+Sx40vjacrxv7+Naaunei6tACUzo6r7GyFMNbC1bNauikBtirK3MQwLupVhCUpNpu
+tqD+8QiC9FJ/nchAxxJLjs8w9reA7nO+kd0jsXhLU7TcYN9j3zHZ0MEM3IaWoK+V
+pNvvkjalhY5LkAJCRcUaLJJXxE4kE5C0VIBK8r904WpOB55htG4UUf5d6FN/pbFM
+UJm2IuV6PMdoSEJZinXIa0prHksBv/BYxWD9jqMuYQczZVuMnJIcJuO+F33iSTAh
+jW+IgrKhX0+rmQfr+0wpCVHYM4Xax7Gm9Evt8Yo31qHXNqyVNeGi1HBCy6T6Plhx
+L+ehquvUo2nZGfGtjaXV+g==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcd-bc01n02.hswaw.net.cert b/cluster/certs/etcd-bc01n02.hswaw.net.cert
index 5fb1c6d..8994a7b 100644
--- a/cluster/certs/etcd-bc01n02.hswaw.net.cert
+++ b/cluster/certs/etcd-bc01n02.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFHDCCBASgAwIBAgIUfKVgcr+CKsr3u9FsVXFRZRXv8B0wDQYJKoZIhvcNAQEL
+MIIFHDCCBASgAwIBAgIUBPBhiwhMaJS05SkiqtKXPGIKJCcwDQYJKoZIhvcNAQEL
 BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNjQ1MDBaFw0y
-MTAzMjgxNjQ1MDBaMHcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
+Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMTAzMjcxMTM3MDBaFw0y
+MjAzMjcxMTM3MDBaMHcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
 ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxub2RlIGV0Y2Qgc2VydmVyIGNl
 cnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAyLmhzd2F3Lm5ldDCCAiIwDQYJKoZI
 hvcNAQEBBQADggIPADCCAgoCggIBAMKzDo59mxEhwx8x3ju95KmjbND9wCga9YGf
@@ -21,10 +21,10 @@
 AQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSv6oLJS4Ov
 F3EnzsT1IOfrUVwQszAfBgNVHSMEGDAWgBTxWbhmTT7D50LugaPURn85U3X5DzAc
 BgNVHREEFTATghFiYzAxbjAyLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOCAQEA
-Dd6WnaNXELgxr/xJVCSFSMiD8e3FjSpH/k/4sCUzonhFS/vIm9b8xEAT0p+bkL/4
-XCgRE/mjQQgdSFEXmZ75AEe+DqYDSjfoIJHAVxJzi/3uexd4+EauVQ4XZh8RMk05
-1HO3gP3wO8RFqUsTKGOTriUVF1zaIz4UxJEzT2BWJkgp5G60HUqXUyaKwNhTDNZL
-p9yzVpsuPHuRlyRZjAHdDafaW6sTFZWAQXxao2NKLMhSi3JLArlJuBuh/4QjhZzw
-UW0U/4yNot/H/kX6Nh41OoBY/mdGGTN7CAndFnXMEEVAuM3gja8LiG3VwDG2bpX7
-C4FJ50A5mcfOEOvnKLA6Zw==
+ilXcdHIWRduN07J0Z5xl6sJs9zYxiGW3twHpUsPZ3eTIvrT0z76RBBgUn/Qlz1dG
+jucc2lkKa+XmUpBgpm/8WNqf2lxrXXrj1KDeosZAppwxFzfhMktFHfVJH3dujbwb
+5eWnBHPp0rMluM/23Ep0hUSHTrGOmxrKplfptFqZJ5AJ3PiXKhxLp/lltUSdgngV
+x9q7fY02d5XaGP2l6QNvO7cSeq7mfvhPXuhmVFPNEU1uzrxuamVWaNcoJ11IeuUd
+JGQOfaymfj6ScOt2zQEpC2OVho5aMhWOPK7wJZ1++mvoVJ0m/hlCLWFKgVpDWocF
+n5ss/F7Gxsa7zGgYBRBwpg==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcd-bc01n03.hswaw.net.cert b/cluster/certs/etcd-bc01n03.hswaw.net.cert
deleted file mode 100644
index e2575f9..0000000
--- a/cluster/certs/etcd-bc01n03.hswaw.net.cert
+++ /dev/null
@@ -1,30 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIFHDCCBASgAwIBAgIUZ5SwY4VA+3YXJ2IlKaB1LVPOYv8wDQYJKoZIhvcNAQEL
-BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
-EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNTE1MDBaFw0y
-MTAzMjgxNTE1MDBaMHcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
-ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxub2RlIGV0Y2Qgc2VydmVyIGNl
-cnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAzLmhzd2F3Lm5ldDCCAiIwDQYJKoZI
-hvcNAQEBBQADggIPADCCAgoCggIBAL24XRDw0Edxw96O9y2XeqlccanVjZQsJRxm
-tvhImPps6U9rIS92/6IIFJn1Wi0NtE1W6VD7p4Q3gK+VmXlCdFmyJRjaeD9zJ8Gz
-0A4bQDtL9nRQM0MhkESKZBxJYkRPMUVDsBKDhq9Y31XTvsqKCYKzErPcBbuq9o8k
-sLoFijUV1EPNZtqnrGB66uQ+nspUmQHAW+aYFxJ4ak0ddSiilZdbCnWs+NpEi71d
-dIeu46GTz31cqhTwLZfvARC9lP7xCXFrGbqjr9wStfJ46sZ3E8xglhYSuKdfpCK1
-TeJGcHoa8H7cGorlqw1uocs6AzorzCE1P5I4eDVN+M6TB6Jv8T83G3JqQBKmcs5Q
-y2ERVDNx9Z273lHO42RXkhTSDjs0ri8tvYr2T4ePTtI6P8E78zC3Y4VdJ/KKiOec
-qgBgeLSFiNFhAPHR+3uOjFZYaAplWcwo9TdMAEX6AoXGhCxjEDnYp4ejgWef3BFy
-t1FL4c0JOoWjogk2FIS5uQasYSvYBFdBK2cAnrxE+w5S/Sx1TB/GC7iPiTPlSI27
-nHrz1MGXZY+WVgFQsxsElsuuuDJFtrYRZNkAAdsuyGI/F4l3AEH0NJaXNrrHBxAk
-clYuIz7tWISncJdG3R20Yl1p8hE41Y8CDse9U3AMMpyT19MPwqsuH/aOBJcSrIlE
-v22KG4SBAgMBAAGjgZ4wgZswDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsG
-AQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTTATwq3vWr
-hLC2f+LfP1iC6GhnBTAfBgNVHSMEGDAWgBTxWbhmTT7D50LugaPURn85U3X5DzAc
-BgNVHREEFTATghFiYzAxbjAzLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOCAQEA
-StQ/e2yaZPH3wyNlLOIzID3u9WwpEyT1RVyc9pCyMzHkEGUAinHzy3X2l1XUKP1G
-t9c+aU4+7+uZgEGsGwXyT7KeoT23U1hym6DN0Azz9r0rGGvBbwyShwO9C2S17wDE
-p/6ZrdXZ3jrHhaspgmv4syAYMb0Z3MtVBpcp2M9EZZSJxxV4G789ZQbklJunKLEA
-U53+YTuzgIeARc8b8H8V8tGoX8799EytDKajm2SEXjXO2hkrSL9AnivT/0sWtEhm
-C8IS/1gS2EhzEjA/vSUjlk4acI/9nbPXOGJeCf3eeGcybx2/1QY7u9ZGXwuqsG22
-mvS9hZ09yn7stK+5RxYgQA==
------END CERTIFICATE-----
diff --git a/cluster/certs/etcd-calico.cert b/cluster/certs/etcd-calico.cert
index bbcd91d..601ae3b 100644
--- a/cluster/certs/etcd-calico.cert
+++ b/cluster/certs/etcd-calico.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFBjCCA+6gAwIBAgIUZmjAlqCosP9W/6X/iIMLRrNb/bkwDQYJKoZIhvcNAQEL
+MIIFBjCCA+6gAwIBAgIURfleic2TJVIf8/RH1K4m9wQpqUEwDQYJKoZIhvcNAQEL
 BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNTE1MDBaFw0y
-MTAzMjgxNTE1MDBaMGwxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
+Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMTAzMjcxMTI3MDBaFw0y
+MjAzMjcxMTI3MDBaMGwxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
 ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxyb290IGV0Y2QgY2xpZW50IGNl
 cnRpZmljYXRlMQ8wDQYDVQQDEwZjYWxpY28wggIiMA0GCSqGSIb3DQEBAQUAA4IC
 DwAwggIKAoICAQC9VRlI8AnvZKPMhtDef9mrYydfINlI/QS8+CE8aLkFfhQQJHyj
@@ -20,10 +20,10 @@
 o4GTMIGQMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
 BQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUYjrCSKoTAgECdKZTntUm3/7d
 2PUwHwYDVR0jBBgwFoAU8Vm4Zk0+w+dC7oGj1EZ/OVN1+Q8wEQYDVR0RBAowCIIG
-Y2FsaWNvMA0GCSqGSIb3DQEBCwUAA4IBAQCQV/w/TOPjnCJszWqLd5GeoEkPPE8o
-qevGTmTpm+/l/vlFERDsUB2kKnxb4mHBbVo2c2ux0aF53wKeIcp5/fdfH1LFjS67
-YY9hLce3zFmZMCEbkFGgpjQKpNy4zB72f4ksGRzbPienFLhghhY1dIv5Rdrhyz1O
-xhrUP9fgJHjYd33pFVfhyl8mIOon8yn+4AvGLrPATgp4dmkF+HM3EYtqd2LfeHVy
-Dc9PbjpmIGHz6IKpMKC4S6rlnnfzbk2PRULVcXHPtfX9ihXz+B892IORDK8jgSe8
-PdVe3ZGMo1EbSwQ9WwGZMrIqeiX1vfaEOhZgaw8GMuFM75OBK9yRopZ/
+Y2FsaWNvMA0GCSqGSIb3DQEBCwUAA4IBAQBt/hQm567Ov+Qptp5QipPUn4Isv/Uq
+zZHSR5xpBMiCl3IKoQuzWyjpbQNey9QAyT/m/7mq+T7p4uKsFKaNQOOOQOH8TNpj
+fWiVwUEQWvwv98zGNYikFD6c+9st+Psh8xsPjqonMgYAra3jn7pNHBV4thQ1i4cm
+YcCUT7ZLw+tyC1z148bctqIUy342srxTupC5xsfv8xkITvakwmwBfNCbeZNxKzJS
+i/zl3EK6y+/biszXGresskQ/fTVPrWfC6oGudv8AG+SASocYztYS41BCoYuy4L8d
+tIB532AqywFH5S0qMXB73bObaJy88A5qdK0kd4sMrxJ8q9NNeZy2AdaD
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcd-kube.cert b/cluster/certs/etcd-kube.cert
index dd0b91f..e82996d 100644
--- a/cluster/certs/etcd-kube.cert
+++ b/cluster/certs/etcd-kube.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFAjCCA+qgAwIBAgIUSc+yoWMgxteBtdswAaa+RZmh6hwwDQYJKoZIhvcNAQEL
+MIIFAjCCA+qgAwIBAgIUUEhDcCROm93ooEjAxFNPNj2lqycwDQYJKoZIhvcNAQEL
 BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNTE1MDBaFw0y
-MTAzMjgxNTE1MDBaMGoxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
+Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMTAzMjcxMTI3MDBaFw0y
+MjAzMjcxMTI3MDBaMGoxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
 ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxrdWJlIGV0Y2QgY2xpZW50IGNl
 cnRpZmljYXRlMQ0wCwYDVQQDEwRrdWJlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
 MIICCgKCAgEAw5jdxQxtELgpg+M1jib4VklzIJ4ULrKH59xKxs6qK0iGJClc8EFv
@@ -20,10 +20,10 @@
 kTCBjjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
 BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFB3FpZrvkz7oq25DKwzCMiKCCsgM
 MB8GA1UdIwQYMBaAFPFZuGZNPsPnQu6Bo9RGfzlTdfkPMA8GA1UdEQQIMAaCBGt1
-YmUwDQYJKoZIhvcNAQELBQADggEBAHGsNqvBFSQC8qWqTTCtghbmF6nQqyhwEgsn
-L/29QMgmNx234r41JPymEN8R2bMFHuMDrOgEliXcbqcirpOuCvx3nln8gFmimtN5
-q7MEjAllJHqa1sjx+O83TNToFkmc8gxUsDqdngrsS1IoEbs8tIP7P/Y80gshCUfz
-2Fnqv1/m8h04RJ7E8Vgxq9txR3JmZPWbFtiHTb7Izlv6y9c+mtCDwzFb8ZiXCfSe
-TFBOptEjx5S82kj7LgYvtngz99ZojoCeQnqczbnHagw7yLH+NqF1SpTr6xZKzdQ+
-hh5Zna0whQCAy2wF5paW3asPNb7Sh90xXARynYxuvCKAIeurAI0=
+YmUwDQYJKoZIhvcNAQELBQADggEBAAJKhH5A1TrRnfkNK/ukfbZ08c4xI1jJaJ4X
+iQ01uxL8j2L6mMiyDeFCjybdg63N/x6dVjvP+fFHd+uF9gJBq/40XD9pMXd4yFeg
+UPzuLwLEJCFEdebsl6u0SH1Gl5Enw5mGUeK1wefX0pfV5JpcLGKTE3TtiksKFYKd
+dzASP1DF7U0zGpiWjdhsIG0ISGZioUvSOCTj9t+qI7rDygaUPtSFFmSIYoiCjeSN
+qsfA78ZQA0N9a1wwmvjl+P4V0uznCcXfUQpMH6I6wDGXOwE66+h7ZJoAu2fJeTah
+2qWASJP3ojXF9Pfg5nwDvAUgAh/xZfc3zOJAIfGAuFHw5wYZ3JQ=
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcd-root.cert b/cluster/certs/etcd-root.cert
index 604ff83..0e22922 100644
--- a/cluster/certs/etcd-root.cert
+++ b/cluster/certs/etcd-root.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFAjCCA+qgAwIBAgIULK8H0d1v3xxIrRUgoGwW+5x6GIIwDQYJKoZIhvcNAQEL
+MIIFAjCCA+qgAwIBAgIUJMEMDyux9E0GJObItOTmYZ1byL4wDQYJKoZIhvcNAQEL
 BQAweDELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMDAzMjgxNTE1MDBaFw0y
-MTAzMjgxNTE1MDBaMGoxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
+Y2x1c3RlcmNmZzEQMA4GA1UEAxMHZXRjZCBjYTAeFw0yMTAzMjcxMTI3MDBaFw0y
+MjAzMjcxMTI3MDBaMGoxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tp
 ZTEPMA0GA1UEBxMGV2Fyc2F3MSUwIwYDVQQLExxyb290IGV0Y2QgY2xpZW50IGNl
 cnRpZmljYXRlMQ0wCwYDVQQDEwRyb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
 MIICCgKCAgEAudCBVJv66aRyizfh20/PeIt7iSP9HpTorxxtG0Ti7ybjhu7gpkPY
@@ -20,10 +20,10 @@
 kTCBjjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
 BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFDDZWVeg57MP21XCkTRqzFR2Omtt
 MB8GA1UdIwQYMBaAFPFZuGZNPsPnQu6Bo9RGfzlTdfkPMA8GA1UdEQQIMAaCBHJv
-b3QwDQYJKoZIhvcNAQELBQADggEBAD+Hf/kVNyXPbWL7asmMsqcldGocnzVFDK8J
-91pWLPSdbAexSUwP6sq2yHUPYMH4uVwjQOg3nKR9GImRJpHkudwZ8M876VdqmCBS
-/KgwlCWQtoN6cw9fQXGnPlJA+LN4q/YBQv0KRN1/eL/jKMPZZL3f0Hy+/4uOvK40
-L2RgNcoXhvRWsJRN+xf00ZvATiHxyhq/uC2dfTgpdCFynl1X700Z6Mk600J6vbo/
-FtGdj6F7nKwi00g2236tb3BEaL1vl2xtdm36xIDmX6F23p3dtRdIl1ULPFg1qAoa
-g8QjcUxILkhwadgKmiUDxVPA+1/afYRklPcHGziB0VJfOTgz1Rw=
+b3QwDQYJKoZIhvcNAQELBQADggEBAKRWkv+IZebFHEylkopUxxiqwX3vhkmDVQsX
+bLUaQY+1xV8mskatzL+pgK233penvibmSnqT9/AKq9iRwKHHEaN3Kr6gnh5bF36h
+uaZfrfCQg4VS2Cu5F1Y9R6ox2LUaAt/9jZRh+OXXI01GjqV7yX4wWzGs5gWrF1e5
+cQoXL024gbYxXYtM4Q/F9rVdMy+vZOuaspXaCh0BIumEDXYQdGE/AGgOWlEbP5Wj
+6NjMVg1+VVZ7GHbyxSjtWh6DW6booHVw7vsNcPafcVW2D7XHyF64YVKQql1/9BNM
+kvJsZgqI9DdWk71eS1laQTGBs3msd/yJTHiZi9COwROaVb8D6GU=
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcdpeer-bc01n01.hswaw.net.cert b/cluster/certs/etcdpeer-bc01n01.hswaw.net.cert
index 0c3d762..e9631c7 100644
--- a/cluster/certs/etcdpeer-bc01n01.hswaw.net.cert
+++ b/cluster/certs/etcdpeer-bc01n01.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFHzCCBAegAwIBAgIUbtFJibIQ+L3FalsNIadl2cqxzs4wDQYJKoZIhvcNAQEL
+MIIFHzCCBAegAwIBAgIUKW8pdXo/W4SfRl9QxvqFh7ePx1swDQYJKoZIhvcNAQEL
 BQAwfTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEVMBMGA1UEAxMMZXRjZCBwZWVyIGNhMB4XDTIwMDMyODE1NTMw
-MFoXDTIxMDMyODE1NTMwMFowdTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93
+Y2x1c3RlcmNmZzEVMBMGA1UEAxMMZXRjZCBwZWVyIGNhMB4XDTIxMDMyNzExMjcw
+MFoXDTIyMDMyNzExMjcwMFowdTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93
 aWVja2llMQ8wDQYDVQQHEwZXYXJzYXcxIzAhBgNVBAsTGm5vZGUgZXRjZCBwZWVy
 IGNlcnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAxLmhzd2F3Lm5ldDCCAiIwDQYJ
 KoZIhvcNAQEBBQADggIPADCCAgoCggIBANQ4DOd1qBoznMwL9bcmjZydm0C45RPY
@@ -21,10 +21,10 @@
 CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQ+AVJ4
 b1WbetRtLuoHoZkRG3TyJDAfBgNVHSMEGDAWgBQte5qvaKoZpMwqiL74duS9J6ck
 DjAcBgNVHREEFTATghFiYzAxbjAxLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOC
-AQEAoeQruvpOcvGc7qr1OWFycJcwEd8k4o/1Oc5KPEKlVDNf9XcweJOetAayBMMK
-Y3lGkBuOHQ5Crx8kXruiQyi6c7tUd9rVtDWWwcLAR20CkDCYnJNn46djgRR6J4pb
-RFz+31cIDgQT2GJhO2waajrcqfrBZeuDyyqt3ZUn3hpICdTbYJWV3x/vqLIY+FpT
-Z+x8Muzb0EWFXCBxZBHsZXBuoCjtmmrNrf0ek5Ag+n5fxl1AdGAuqD4D01wJWa0L
-UJtaLmuD02Pw/c6RJ5vkWI9DRcH7/XdYZZ5yf7Ch2GNivKsz9ekIVpVz0HJMOVPM
-yw/s8FL0AczmICjx6yjlu4RQ5w==
+AQEAZUKCrNDpDTJBVcVswSaEQujA2eNOTQuj6eBuf08Nz1NZGgRYAfy+aHoTkahZ
+m5FaYFSEnL6R0qzjruvRSnBb21A9P6+8+T2Tw6NA4XWDqKwIoDzVyqjpTNeRpcTV
+Xj3l3eJPSsncZDmPrZIY8LBG9ZQSe36KfYvXPPCJT3aYLKu8ZfiJRTbxU/gH4SQg
+wgKZZin/oBqnmWWhsKdVsepCDtZHUwxa0xdqLnmjlQiCjoW8tdyS6F0EBnK6nUY9
+V18h0b7eZWPy5u+t1wnNA1Jjsu9ad0/4IipJ7oEkVcufZcWhuxgfaxddh/a+eRuB
+lQQ+/YNCpGWlQBsD5tIxpEasFg==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcdpeer-bc01n02.hswaw.net.cert b/cluster/certs/etcdpeer-bc01n02.hswaw.net.cert
index 8776183..f660e59 100644
--- a/cluster/certs/etcdpeer-bc01n02.hswaw.net.cert
+++ b/cluster/certs/etcdpeer-bc01n02.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFHzCCBAegAwIBAgIUHPJfFvMMibUlrbwagmA8Zy9KtS4wDQYJKoZIhvcNAQEL
+MIIFHzCCBAegAwIBAgIURC6k/INjt9VF6OGcTjPG/IvihO4wDQYJKoZIhvcNAQEL
 BQAwfTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
 EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEVMBMGA1UEAxMMZXRjZCBwZWVyIGNhMB4XDTIwMDMyODE2NDUw
-MFoXDTIxMDMyODE2NDUwMFowdTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93
+Y2x1c3RlcmNmZzEVMBMGA1UEAxMMZXRjZCBwZWVyIGNhMB4XDTIxMDMyNzExMzcw
+MFoXDTIyMDMyNzExMzcwMFowdTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93
 aWVja2llMQ8wDQYDVQQHEwZXYXJzYXcxIzAhBgNVBAsTGm5vZGUgZXRjZCBwZWVy
 IGNlcnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAyLmhzd2F3Lm5ldDCCAiIwDQYJ
 KoZIhvcNAQEBBQADggIPADCCAgoCggIBAMWfZpYs+wH/qg95+KXgIPMDUN5fuwmp
@@ -21,10 +21,10 @@
 CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQzpNgs
 s57/rdxm88PxZpnFUvIiTzAfBgNVHSMEGDAWgBQte5qvaKoZpMwqiL74duS9J6ck
 DjAcBgNVHREEFTATghFiYzAxbjAyLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOC
-AQEAiKtRlm+8J5X0/SvRK9urYX7KvgE+yEYQ+pm4ZXtxbk8aXqbwnBiyH24+0mlK
-Csn/iLv5b0GS4pZcjczBA+SE4Yi4Vx7Ekz0byagYPj9idlQYRX4vl/Osmm/svj5f
-KWyv2/rdaq/0rkoPvnT29uC29CDCqPguaS8zYgVOinSfdN9dfPw10lBiCgSLLacd
-ReI74GCgUVv14QlWColz0ILtDmwKS3nEnDtNVBfMR5jxViSiG/1ZWZt2C+/66kAr
-YI1LXxkg/ZyVtArSe3uDqWL/oGOcGHoj8QnOljDn/7GcNCH4NDfQ+KkmjqIHygYo
-aKEgxEqoYvpE9ls1tSaOXS2iLw==
+AQEAje4XXzWeRhe68C95xlJEmVcYUft5lj30ZWEjWI0+xzv+KCEQ7KiE9tgEgDI3
+TPROCNoolLIRbOEsuVliYzxLzMvgJsA9rwH8BkrbP/4Cj6yvMPUwrq/C/kRHukvr
+uKhdOY/XhvpNstO6CvGpehyeu55rGgrLFJLJ4TLm/kTpiXdb7btFd9QX0e54ReEg
+Q6C8eFOODyTR3rSgaI6Wt7IlNNCMCvjtQ2z+9TNNzGPJloFFwJES2wFT7lXJPsxQ
+nT+T13YyoFPUfII0C9tNy6+xJdMMh+pCCy7DH/pQ4DNr6t5J4CZqdJLrlJ4eaqQG
+csf6xcegXNuoDAW+csi1VnUyDg==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/etcdpeer-bc01n03.hswaw.net.cert b/cluster/certs/etcdpeer-bc01n03.hswaw.net.cert
deleted file mode 100644
index a67d385..0000000
--- a/cluster/certs/etcdpeer-bc01n03.hswaw.net.cert
+++ /dev/null
@@ -1,30 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIFHzCCBAegAwIBAgIUY0PqPfxno72apj4xsBsQPC/QTbUwDQYJKoZIhvcNAQEL
-BQAwfTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQH
-EwZXYXJzYXcxGzAZBgNVBAoTEldhcnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMK
-Y2x1c3RlcmNmZzEVMBMGA1UEAxMMZXRjZCBwZWVyIGNhMB4XDTIwMDMyODE1MTUw
-MFoXDTIxMDMyODE1MTUwMFowdTELMAkGA1UEBhMCUEwxFDASBgNVBAgTC01hem93
-aWVja2llMQ8wDQYDVQQHEwZXYXJzYXcxIzAhBgNVBAsTGm5vZGUgZXRjZCBwZWVy
-IGNlcnRpZmljYXRlMRowGAYDVQQDExFiYzAxbjAzLmhzd2F3Lm5ldDCCAiIwDQYJ
-KoZIhvcNAQEBBQADggIPADCCAgoCggIBAL7GrtNL0y7cK7qu0z4d8WdWOTWbzlBX
-xdx+PMDnhWeVONtVUaQ+wD1Js7e9d1myIujZfltWnPzA4OymJtjI3CmFip/9SylR
-ggaGtHaF7nXte+yaoK+Uvc7AD+vRjCnmdnJgUz3yoAogEnGxdeWwy8L/QnGGYNvx
-kboeH3WxLkqBW9BWYsDcDK1uVzEszfpbFYV1CaCqzQ455GsyjFquRZ4pwkPJp5xM
-BYvfo/2jf1AiWDH+wZ3SByL6yxq0skFCcEA/DJTy+ggYVtmjn6C1MDHiQUnWFVR+
-f0CLDR9V6GmQbQ2GbOgY2jLQnEzdKdv3Bc+QVeMs+Fj+wgjAk1vuuKwA/zPCWTtb
-PR0H3NI1RKl3411UgBJ1CUPmJWBsRitqMgFgPIheK2iwK6fMhW1tEp3V9lkNU9ob
-NsCaep+Rlw9c05dKBgatj8TiPJd5ZTyw5SnC+UZ60u9d0wuyku6U6++keVpq0sbV
-s5qP4zCAkoOcrJt1HIyS/a0zPXBHPTrAwk07aZ32X8GnutetO5tUM03+6OuNY65Q
-NW3v4rYP7jW0NvtKZo8KXvmVn/1sltGhYVqGVqqZKvkhbQFN3RIfZMZKUn+d81pA
-2cMAYXtGqn1fcOFIfXBEF2qWpvOrVwEg34pNRU0fwvqh62nfIk4L2iBzI4rpj+Pv
-CEI53DIQlVLjAgMBAAGjgZ4wgZswDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQG
-CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSQjE6Q
-JEigxzqbPAfgSJNQH+MX6jAfBgNVHSMEGDAWgBQte5qvaKoZpMwqiL74duS9J6ck
-DjAcBgNVHREEFTATghFiYzAxbjAzLmhzd2F3Lm5ldDANBgkqhkiG9w0BAQsFAAOC
-AQEARkvDE+dGvSXWF8iHeXrasd+MoZz6+ifpFA27dsGLDfbEfmbks+nbJCAGhIqf
-YqKJ8H+lopIBDtxbcRfTjXDLsncmNTrWf/FVEx/WLbGQsYrqncW8ym2Xz1qBCMde
-/QBKKXSEEiBpVLYn0+41dvtxw8nAfIPin98pScnY04p+5BGaxFsKb2CsshBJlDOR
-g7BFYiWpQz2mAr6tyO/lG2KqN0B07P07jszej0c8Xl+tkbMtpghIi2GcJ75VXNJf
-XRtmeTw7YqcLgKTknBfJ9+GotmFUfAF2f+jA21URWY/6f4Yr+BniZj6Ikahx3eD+
-fw+y3c4eBLl5G3WugIFj5cM0UQ==
------END CERTIFICATE-----
diff --git a/cluster/certs/kube-controllermanager.cert b/cluster/certs/kube-controllermanager.cert
index 291c604..10ce219 100644
--- a/cluster/certs/kube-controllermanager.cert
+++ b/cluster/certs/kube-controllermanager.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFdjCCBF6gAwIBAgIUFcalvELfl2XL6uFIqLjD+5G2tD4wDQYJKoZIhvcNAQEL
+MIIFdjCCBF6gAwIBAgIUGOVHVh0NR+sSqL9n0ALIrUHxVgMwDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTE1MDBaFw0yMTAzMjgxNTE1MDBaMIG3MQswCQYDVQQGEwJQTDEUMBIGA1UE
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTI3MDBaFw0yMjAzMjcxMTI3MDBaMIG3MQswCQYDVQQGEwJQTDEUMBIGA1UE
 CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEnMCUGA1UEChMec3lzdGVt
 Omt1YmUtY29udHJvbGxlci1tYW5hZ2VyMS8wLQYDVQQLEyZLdWJlcm5ldGVzIENv
 bXBvbmVudCBjb250cm9sbGVybWFuYWdlcjEnMCUGA1UEAxMec3lzdGVtOmt1YmUt
@@ -22,11 +22,11 @@
 BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG
 A1UdEwEB/wQCMAAwHQYDVR0OBBYEFAjGzAQ6wd1qf2er14tBEmwSV76JMB8GA1Ud
 IwQYMBaAFJgyXQ5PMx77CemJJBMWQmqA0ZnQMCkGA1UdEQQiMCCGHnN5c3RlbTpr
-dWJlLWNvbnRyb2xsZXItbWFuYWdlcjANBgkqhkiG9w0BAQsFAAOCAQEAjeeYwCxm
-8yfTfiWSKMMW9HTlK7zAl8PKngOvARihFgUfO1MbWQLTqYvkZ8/b8d3tXqxGHIY8
-TqLtK0N4a3ty+7IFwqnA29+apSPQOjK2f6RfSwUPFLqGDFXcM0pHHRbalYmUM///
-pEu3B223yimrtacAj3NFy1c5Jd7V36ZhwBAzzh3raWpqbuvm80MZccmb1gml1a/G
-KuyLzrtu8N6ObpsMFQSDbelgvgVvNh0akPANqrDc8BG5gDWkurIhsRWQC+POMKlE
-/c/EgyGXpZfvLtTprgCz+RRIGhV3GNRRbN/sdc3jO7M9QyJod3639VCf5gg5EU2l
-5sbxhHL+mvqJwA==
+dWJlLWNvbnRyb2xsZXItbWFuYWdlcjANBgkqhkiG9w0BAQsFAAOCAQEAterGoD4V
+aBT2qxqsUrZKcg9RyPyKNZM+ixZq3boOw1+8x1mryKrmb8pNS9BV28jbhwFu5G9I
+XIwU6Ugzf18LFHUZj/MRGEaKz+lNfwbgQjFrNu2fZaqzyXBhnvbiPOERfzDn6s3R
+35gj3AO/1Rg2DR0qGQhNw1IiRerg+IDBdNq1rVHZ8qJi8bwLmT1GnWNinzEnOOKT
+i86oS5z86Mqa5DQQt2EOUtzm5XSBLfHj1xciBSMqRJzoKThR71lUG50Vcoha5ra8
+6ZR/iOvdMEUZqElhZI31tToGikkJ0RQ6xLnSnC8GxNcGzI+m40U77Ltof5xKqZPR
+1iMUGkJYznjjcw==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kube-kubelet-bc01n01.hswaw.net.cert b/cluster/certs/kube-kubelet-bc01n01.hswaw.net.cert
index d846aa9..08b5db4 100644
--- a/cluster/certs/kube-kubelet-bc01n01.hswaw.net.cert
+++ b/cluster/certs/kube-kubelet-bc01n01.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFVjCCBD6gAwIBAgIUfBwlWrk6L56SB1jAWUjjt4c5rOUwDQYJKoZIhvcNAQEL
+MIIFVjCCBD6gAwIBAgIURyres5fkxiPk8K2ExffpNz+1ZTUwDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTUzMDBaFw0yMTAzMjgxNTUzMDBaMIGFMQswCQYDVQQGEwJQTDEUMBIGA1UE
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTI3MDBaFw0yMjAzMjcxMTI3MDBaMIGFMQswCQYDVQQGEwJQTDEUMBIGA1UE
 CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEVMBMGA1UEChMMc3lzdGVt
 Om5vZGVzMRAwDgYDVQQLEwdLdWJlbGV0MSYwJAYDVQQDEx1zeXN0ZW06bm9kZTpi
 YzAxbjAxLmhzd2F3Lm5ldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
@@ -22,10 +22,10 @@
 HRMBAf8EAjAAMB0GA1UdDgQWBBRQZwM3WgGW+l8MKQBF2DZ5IWBh6jAfBgNVHSME
 GDAWgBSYMl0OTzMe+wnpiSQTFkJqgNGZ0DA7BgNVHREENDAyghFiYzAxbjAxLmhz
 d2F3Lm5ldIYdc3lzdGVtOm5vZGU6YmMwMW4wMS5oc3dhdy5uZXQwDQYJKoZIhvcN
-AQELBQADggEBAKfYV2qyVs9yvsOhjKo8/A/t8Juz6FCBbPuuTdUt0TVENro3/njr
-wvl+TrtdvRwOfgYbzl+UUKgmwOY9gBumWeEOHeOUSJS+Cz9Ad5YgrQzQ9T9stOAC
-/8DdGq4rMaU2tbxpNCAc38XJUmwOnSrVbreWc98LZSlV17FFoB45R7FFhz0VZHpM
-B0/I+fK/IkF3CbSHVViGqjPcTwASU4AQaw8mcEAvpT4yXF7nUY+jORZSH/48y6U4
-6hApMGjLrdnSyCPddvPxfW6BFw15TNh2PnCNIb5P0fwitjVcArtock7T/vHVe+8Y
-+Xc7y0kqHBNKNDFRaA5I4HIF+Jc+kzZgeaA=
+AQELBQADggEBAAbi2ovuQnqWwlM9QVtWNvitIUslQ8X3ghpW7ZcnurfGeCZLBvoX
+p4dWpzMlh7taHVpERrBEamRuANuOZZciQjBrbfAWyL7K+vhpiUa0Y4q5hAafyoe/
+vIOzgmP6wCD3mbyFZG2TbX8yD1A7lycrvEqbCA87ujh3vUmbscKeUKg8RjjcqZaL
+GmdM9iDmZwuxZRSB1tpIGTT7mZJw8MAYY8gqyCBxtFGkuQELxjErHl2mIHo/Jej6
+8oGl7EVMQHiyXQ5H8aFIWu+HOAauX/V3HcaynMG06tFLApLNZ8DWaRcDg+XqZZ5U
+b14Dddmcj5/EtddCJYbobajXmQ98i8s0fpw=
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kube-kubelet-bc01n02.hswaw.net.cert b/cluster/certs/kube-kubelet-bc01n02.hswaw.net.cert
index 919dcc2..22a1472 100644
--- a/cluster/certs/kube-kubelet-bc01n02.hswaw.net.cert
+++ b/cluster/certs/kube-kubelet-bc01n02.hswaw.net.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFVjCCBD6gAwIBAgIUXvb9hqtoTXFM458nQblwXTSNeLAwDQYJKoZIhvcNAQEL
+MIIFVjCCBD6gAwIBAgIUcy5dWej8r/XrcG0CFLw1vsYbHMYwDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNjQ1MDBaFw0yMTAzMjgxNjQ1MDBaMIGFMQswCQYDVQQGEwJQTDEUMBIGA1UE
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTM3MDBaFw0yMjAzMjcxMTM3MDBaMIGFMQswCQYDVQQGEwJQTDEUMBIGA1UE
 CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEVMBMGA1UEChMMc3lzdGVt
 Om5vZGVzMRAwDgYDVQQLEwdLdWJlbGV0MSYwJAYDVQQDEx1zeXN0ZW06bm9kZTpi
 YzAxbjAyLmhzd2F3Lm5ldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
@@ -22,10 +22,10 @@
 HRMBAf8EAjAAMB0GA1UdDgQWBBSnIgfLJiK7R+k9wfSCeKuqjpkYNjAfBgNVHSME
 GDAWgBSYMl0OTzMe+wnpiSQTFkJqgNGZ0DA7BgNVHREENDAyghFiYzAxbjAyLmhz
 d2F3Lm5ldIYdc3lzdGVtOm5vZGU6YmMwMW4wMi5oc3dhdy5uZXQwDQYJKoZIhvcN
-AQELBQADggEBAJ0HFPiLL+Opy04Zm3H5bRHOlUcPiSrRUi4QM8PnnrC0t9R1Wvlb
-PvuvAG2EI2rQsN9qi73riOW5KwUmvxe3ArpHH20uhUumBfyikK3nqnQW6XNBzirQ
-pv/2b0Pm9CCn71ETcCrUaenGaUjUhmY4Ojvbp4Ycc5LQ2E4PlsR11GnETM15CK7K
-0z1VUtiu1+XubS+1trYw5aUF3WQGitTDl4T8VCdQRUKeyygO1HMQwmJmRwuMMLrP
-MTbaNOQBD+c+QIzQDE/+yGPkItU2efBmNvsp6B54AHznEkUMrqJWzkt4SnJ2KbAu
-DtzSfnNpaGpluvOyh0NxHsCcUMlm9J8ajNk=
+AQELBQADggEBAJbbXs8GScqQRGJwyuhCOhfxZZytBUjsa1e+52wHBDPgzdetbXiK
+mzy/xp0Ll7Stbl9S3qqw9Cd3bcN5Y+PqxKPB9rjOICtf9Ah7ICL17U0gC97WEtSV
+jqfG1lyavcltufMRaWMDJUcPU7x2k+OpUJI4Z2OXD4RDVyaZ8iIU5l7ML/qn3uTH
+N2uVhQHjdbNqlZvl5Vw600rwnATIbv23kITuSxeqRS4I5qXsK0PpH192dUPCfPfe
+amHvib38cbvH929jGAmySDWOSSI8W0uSwvL3AAMYZG6l58G0xFMrfzZA0g356kyX
+0sG9E8tbZl+NMkEzD6+8IeVG4Gg3SCVrJ8E=
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kube-kubelet-bc01n03.hswaw.net.cert b/cluster/certs/kube-kubelet-bc01n03.hswaw.net.cert
deleted file mode 100644
index 52a01c0..0000000
--- a/cluster/certs/kube-kubelet-bc01n03.hswaw.net.cert
+++ /dev/null
@@ -1,31 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIFVjCCBD6gAwIBAgIUbS3md+5hxDAJRfmGxv8lV5m21x0wDQYJKoZIhvcNAQEL
-BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
-BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTE1MDBaFw0yMTAzMjgxNTE1MDBaMIGFMQswCQYDVQQGEwJQTDEUMBIGA1UE
-CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEVMBMGA1UEChMMc3lzdGVt
-Om5vZGVzMRAwDgYDVQQLEwdLdWJlbGV0MSYwJAYDVQQDEx1zeXN0ZW06bm9kZTpi
-YzAxbjAzLmhzd2F3Lm5ldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
-AL39o8eEvKxuTomo/6bb1UyLyjqXHkhqCZOvAQjPTq+GnCikZZj/n2cbIMJ09duO
-sItQtOy7wUWH9gDnlgp8fW4zA+AubhtXITXGTVMlrXps3VWAA3ywxIT3LpeXOHdz
-RLRnSK1g5J5yB+Jid10/Fz8pVV7GTTUnkDVsglyWUkXNpuWJNGrjQacb3l0n0Dr4
-6BssLWSZl0id7Hq3VlFHjgvjGnUkPzYFnhMeS16oAI24diASmAVw3K3nYtQoLroV
-DDN32o7+lAlPMLjVHlCoXUts/lx4Ikm+iQ1JVj5JHgWccg+wjJdH+K+XYaizOcpg
-jsiXli4oxTV7zNxkj/9n75b6XTkeq+CfnYTNZE/iukdlBtZaH9/jCy+c5mZWNPwR
-z9dvkMSA1hrHjJJI70nz6NSsIufLiiBfuqE0JjQvJzvyCS/ioj3xfQI1fVRf1A1E
-Cr2R2T+AXWKPOqLZWtJ/8BY50xMPNZYRDPAuYCRAKoZ7EKr5YbEKzMgk9pMJMYaX
-wASmyBAxOQlfjUBs4nQpgL6XrJ8EQzkOsRJPtD+MjEG9JkSf71pzIIexlwj1NnYa
-/dPdIKaO6UBD3WMJjPhFozRgvmJdzRtH4oJ2p0NpMzTxzSOqh3yyfWQl+Gr3bxWr
-H0ShXe8QV2yvt0ISPzJjHNxG+pv79moribfs1gX5KUYpAgMBAAGjgb0wgbowDgYD
-VR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNV
-HRMBAf8EAjAAMB0GA1UdDgQWBBT1m3RgBiqGCDhqwxutcUg0f9nBzDAfBgNVHSME
-GDAWgBSYMl0OTzMe+wnpiSQTFkJqgNGZ0DA7BgNVHREENDAyghFiYzAxbjAzLmhz
-d2F3Lm5ldIYdc3lzdGVtOm5vZGU6YmMwMW4wMy5oc3dhdy5uZXQwDQYJKoZIhvcN
-AQELBQADggEBAFmt/mBuQHW0mfWNgc91OhNRUAL4Y23zFy1hpL4t0VNtGwEv51K1
-hTV7GlQHAcjE0Ti8Ivb9b+gU0FV6E1xFDsXg3w/unmZBhFMnKkwR/f8AIadgO/JT
-MgV4XvQgxXwVRetetXbr2uQV4Nz5cji9E2Rcad6NkN67FNpKratKR0+sPWCz9DYJ
-5mPlfmGBBW6ptAMGnekg0ttvup1a2FbCCxKpMnL+X4hv0a05Pgviwemm+uwckl/k
-zTqB7VDYtlS5SloRpHP4D3VxXU6j2vwkV7D/pEWKY5kXTHmBN2VDL+9mU03LB+aa
-NAeu2cme5Cu80BfzG+Eit7AD6hapm1WLmuM=
------END CERTIFICATE-----
diff --git a/cluster/certs/kube-proxy.cert b/cluster/certs/kube-proxy.cert
index c638345..11790c0 100644
--- a/cluster/certs/kube-proxy.cert
+++ b/cluster/certs/kube-proxy.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFQzCCBCugAwIBAgIUNOcUBxeoeF2k8lsdrL+mqCe0O7swDQYJKoZIhvcNAQEL
+MIIFQzCCBCugAwIBAgIUAaW3eAYNLLYjQqg45/6XqRwBMGIwDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTE1MDBaFw0yMTAzMjgxNTE1MDBaMIGRMQswCQYDVQQGEwJQTDEUMBIGA1UE
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTI3MDBaFw0yMjAzMjcxMTI3MDBaMIGRMQswCQYDVQQGEwJQTDEUMBIGA1UE
 CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEaMBgGA1UEChMRc3lzdGVt
 Omt1YmUtcHJveHkxIzAhBgNVBAsTGkt1YmVybmV0ZXMgQ29tcG9uZW50IHByb3h5
 MRowGAYDVQQDExFzeXN0ZW06a3ViZS1wcm94eTCCAiIwDQYJKoZIhvcNAQEBBQAD
@@ -21,11 +21,11 @@
 AAGjgZ4wgZswDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
 BgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTn0MVsvuVE2ZiSJNZfEp6Q
 pb8u9jAfBgNVHSMEGDAWgBSYMl0OTzMe+wnpiSQTFkJqgNGZ0DAcBgNVHREEFTAT
-hhFzeXN0ZW06a3ViZS1wcm94eTANBgkqhkiG9w0BAQsFAAOCAQEAa+LkfAbWHUSs
-19veJ09P0uo6PYKqXsOrh9soWX8lusI3Zt3zhTdSXkzJwKi8bH3zFx8niWAIHNwZ
-mxesvJIH6fPA0/401MkjhSRo3cyMUnjKmjx5+DD2qIEKKBPsr/xNMpJGPKaDjGtk
-YyRHW8Bg7kX+Jc/uv7Gg6U+/xtdbELaxL/USufRN7obC7gNtenXkdOUgINrfllX/
-66+K7yqaAqaCR4gEGSLUnUvkbFZ/+XB7Z1tLKgWurJj5v82ZxnkBI+aU3tVwLtoT
-tnH7OLi5Tbo+RYuf3iMd1vGxVwEPcD9cBUz0lRsK9TTJxRytS8CnS3EIwcitYo86
-+yl7LltiSA==
+hhFzeXN0ZW06a3ViZS1wcm94eTANBgkqhkiG9w0BAQsFAAOCAQEAk1cL0SzdLP2E
+KhV6VNpjGT1/V1u31V6SXueucesstPubEFDUrlGeDeaww8dJXBx694jpGKXBvD/F
+kskLsOTABlK2h/CuXHoWImSZxUmTHOEZLB1u1coAUfy/C1pegg1OklW3judbbQQM
+4mArJh+JNXR6SW3J9Q9HKmgBUV2O5C8PuxMyB51zWkA2ZMLIXtNv4OSjrVRoyc57
+URopH5ZBucq+T+GzmEQB4Xgs39fpvWX+6rqB49TcfF3jqTVGJD38nkVUnfj06Tl6
+VGN2GZiec5/dmVKCqdqFefezQVXjEooA4TwXr+l4xtQECTMapSHBPdHnFavY5PDY
+G53Txq4iBA==
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kube-scheduler.cert b/cluster/certs/kube-scheduler.cert
index abdf6a4..db6c585 100644
--- a/cluster/certs/kube-scheduler.cert
+++ b/cluster/certs/kube-scheduler.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFUzCCBDugAwIBAgIUSlMSojfxDUiCtKO/7Mr5kJbxBxQwDQYJKoZIhvcNAQEL
+MIIFUzCCBDugAwIBAgIUENQVIR+6ek6Sv86i+ELInvCc6GAwDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTE1MDBaFw0yMTAzMjgxNTE1MDBaMIGdMQswCQYDVQQGEwJQTDEUMBIGA1UE
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTI3MDBaFw0yMjAzMjcxMTI3MDBaMIGdMQswCQYDVQQGEwJQTDEUMBIGA1UE
 CBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEeMBwGA1UEChMVc3lzdGVt
 Omt1YmUtc2NoZWR1bGVyMScwJQYDVQQLEx5LdWJlcm5ldGVzIENvbXBvbmVudCBz
 Y2hlZHVsZXIxHjAcBgNVBAMTFXN5c3RlbTprdWJlLXNjaGVkdWxlcjCCAiIwDQYJ
@@ -22,10 +22,10 @@
 CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQw8PPX
 ExubjWf7o/eChtV5jnt1kTAfBgNVHSMEGDAWgBSYMl0OTzMe+wnpiSQTFkJqgNGZ
 0DAgBgNVHREEGTAXhhVzeXN0ZW06a3ViZS1zY2hlZHVsZXIwDQYJKoZIhvcNAQEL
-BQADggEBABw3aqj8FQtaZKHHzGY+cpjvOT1VUKax1k0iQAbYS5/8d3kaToDed05M
-omXDcIxb3VHs6+sWxJYWAiRPiA5mrDdA7XQcfIv1xtP+DL3dbRqhz276XNM4/NIj
-vt9aQox/WSE0HCDTUSN/clYbB6tigLfSxXhnuz214N6NwkcTl8xQVvXxg3z6ryc7
-XUTEA0fvl/fe+KsO2l4kxBk9Ef5cud3j2e4F4l8tFHz1bRXfcEEcS5uLLgK3KIAu
-sf3Sf+t/jcTTrJ+3YVFBAY+F7AN4UjNdlAfyvTG7xB+pxD7RlEd6Ycozd0tYppg4
-VBbXtQ4TOxHLVvrlANi3MJAzYSUuB54=
+BQADggEBAF2/ClyjH45S5oscrhLFWMcv9sVvS9M9FanBFibipUw3HyLvpjorl1fo
+nLvrAxIUHgtDsjkS5lWh4f/LAO8ju1DJA1L5vC4zEMXuWPMcbRCBrx/7q/kQY9yB
+apHlm1oKXQQ6/8Icz0rJZsEfVpqWoQMKRefRZKEYQa2KdvCEqyBHvUjLRG+hLzgH
+5O00AfUoT89DiLQfqXCcYi5GR7ulyrCbw/SlPaFFAglbT7uVLZLC8amcYAEMDb1t
+xvWdTil4opXnI8YQwr4ZopcByqaEomgfILbHrbW4kdEW7Lel+imbjo/lJ0CN+6Ra
+yOYeaOSBmCknhdEzpuuue4f6xJJx+SY=
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kube-serviceaccounts.cert b/cluster/certs/kube-serviceaccounts.cert
index 7ba5a5f..b572b1b 100644
--- a/cluster/certs/kube-serviceaccounts.cert
+++ b/cluster/certs/kube-serviceaccounts.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFKjCCBBKgAwIBAgIUC8G46cdD+fUIfepfl2RRtz7D5FgwDQYJKoZIhvcNAQEL
+MIIFKjCCBBKgAwIBAgIUXRB0XNQ/Tdv1fPq8iQTbrkHH1q0wDQYJKoZIhvcNAQEL
 BQAwgYMxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
-CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMDAz
-MjgxNTE1MDBaFw0yMTAzMjgxNTE1MDBaMHsxCzAJBgNVBAYTAlBMMRQwEgYDVQQI
+CmNsdXN0ZXJjZmcxGzAZBgNVBAMTEmt1YmVybmV0ZXMgbWFpbiBDQTAeFw0yMTAz
+MjcxMTI3MDBaFw0yMjAzMjcxMTI3MDBaMHsxCzAJBgNVBAYTAlBMMRQwEgYDVQQI
 EwtNYXpvd2llY2tpZTEPMA0GA1UEBxMGV2Fyc2F3MSswKQYDVQQLEyJLdWJlcm5l
 dGVzIFNlcnZpY2UgQWNjb3VudHMgU2lnbmVyMRgwFgYDVQQDEw9zZXJ2aWNlYWNj
 b3VudHMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDFSCquXVjQANUN
@@ -21,10 +21,10 @@
 oDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAd
 BgNVHQ4EFgQUliCshdOww6BLgNw1Cu+0XiqCpg8wHwYDVR0jBBgwFoAUmDJdDk8z
 HvsJ6YkkExZCaoDRmdAwGgYDVR0RBBMwEYIPc2VydmljZWFjY291bnRzMA0GCSqG
-SIb3DQEBCwUAA4IBAQB7xM6vfvk3dw9cFP0F2YTAxLVot1E+KzHWz952uIm3CrtU
-Vq3WHBX3NRTVrzg3Ycx4tNniOHBqNrzgksz0XmFZw7VyiY+yEzueVCJ9HU9y8Kb2
-XdL5zqTtgVYspr0dI/34NbGnFVJAOJ57fAc4LxhPwAZMG6s4LwDiBDYIBw+KoJsD
-FOiHJ+AfW5taGONEGY+HuNnSo+RllCgFdjPW0hK4X8Jt4p5Qr+oICO4Nzp4jZwv0
-WqxHzmX4DYDQLztqrQelSDkaQaP/xAhq7nsaK91sMob1OqQcYSMckm2SFEmTwBCs
-VpJg24y/LRw1LPK4lE7GGGIkyko+aDCsIm+YDX7U
+SIb3DQEBCwUAA4IBAQAUVM1a6fB91WStX1xqKBfujxfWba3od5EaHQsKzXqsY6F3
+C87ush2JdJ/4Tq/D6xqcaOY7MUOSuL8LLlBafErbfjfVEKpG1muYwignYn0B0376
+nq9knFNYGQsNP3DwVqoFq/7hoQiyF/aBndL5gTAdoj/C30pIjG/wi8MlLsuX5h0n
+Qip3Fq6kjb6GY49Yo2Z9AchPIYotuubJEOwMLXqo48uWR2FOBrrDNSJtFIm6wcn3
+B2sjpApxv+p+lD0tApHSMbrJhijqAv5MOG6p3zIL62QM+zwBK4dtsOAeYX/cEE24
+tb56uVqXXoCICv2+TyO5NCJRP98ML+/WdHxlYvIw
 -----END CERTIFICATE-----
diff --git a/cluster/certs/kubefront-apiserver.cert b/cluster/certs/kubefront-apiserver.cert
index 409beb3..c7e90a1 100644
--- a/cluster/certs/kubefront-apiserver.cert
+++ b/cluster/certs/kubefront-apiserver.cert
@@ -1,9 +1,9 @@
 -----BEGIN CERTIFICATE-----
-MIIFEzCCA/ugAwIBAgIURk8WW4qapypnrPH9a2aMG+oMUpgwDQYJKoZIhvcNAQEL
+MIIFEzCCA/ugAwIBAgIUMFLqy/yR+qaJxJmqesPU+M+xVKwwDQYJKoZIhvcNAQEL
 BQAwgYcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
 BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
 CmNsdXN0ZXJjZmcxHzAdBgNVBAMTFmt1YmVybmV0ZXMgZnJvbnRlbmQgQ0EwHhcN
-MjAwMzI4MTUxNTAwWhcNMjEwMzI4MTUxNTAwWjBmMQswCQYDVQQGEwJQTDEUMBIG
+MjEwMzI3MTEyNzAwWhcNMjIwMzI3MTEyNzAwWjBmMQswCQYDVQQGEwJQTDEUMBIG
 A1UECBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEcMBoGA1UECxMTS3Vi
 ZXJuZXRlcyBGcm9udGVuZDESMBAGA1UEAxMJYXBpc2VydmVyMIICIjANBgkqhkiG
 9w0BAQEFAAOCAg8AMIICCgKCAgEAuVXNUv1oJrLw9XxagSTyHegDeHT71JSdeYWx
@@ -20,11 +20,11 @@
 /xZzYekCAwEAAaOBljCBkzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYB
 BQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKkQFBbQv3W0
 jUMCdTBkmb0YlS7DMB8GA1UdIwQYMBaAFI+XbXEIcmDLzHcqyvkE9KRqvCX0MBQG
-A1UdEQQNMAuCCWFwaXNlcnZlcjANBgkqhkiG9w0BAQsFAAOCAQEAGI6MmkpsH4Ur
-mU20a8EmXkaGK9pD4hvbnsFsnzR0CjE8KHmDWIKlkg6RJZlnZnsd4UmnJTbnpM6A
-IkOycdwXsD8QrWwOMOqT3mIw4OYZqog4WqXcXlpeQwDmzC5mqEPWmy/Vr0CCIzFF
-Cx2ElcoIBQ46o9hm4Lx91uyWqDFRsBLleE+rgr9nCqesG4kYylT4Tb21l+YGQqtC
-v1mpXD7jaoyVVGpm28zUE2v/bGZsBfmd5cC9MVvyVrlhL4soI1UCO5aP/n/DxY0a
-iJ3UvlNnUbFuo0GUalbIwlTlVK/l6o6XRPINGbxTPEaueDLPKIqvvF+ZZBDMx7Hl
-k3IBIbEW8A==
+A1UdEQQNMAuCCWFwaXNlcnZlcjANBgkqhkiG9w0BAQsFAAOCAQEAaYm8tzCnm2BW
+IijcvHFnEmubdsYXYanIUzI60zQaUQueke9RhM/fJzO0Sga5tCHArU4diQgmimu3
+qtO+WUNNlxWw40XP7ZvxjYZZDCdYa+1fwdpcim8OtQjpIiwMe9rZ1tT6HRBCav3R
+jCzEQo0MrB8rSiwltSKRJRFc+zirmAhFwcD4zhZtc8tEiruG7HD5f7tdzFk1byY9
+R53Uug6iDvg0iyjBiPZ2R9JqUQ22ip1aa0Oymd8yLXjeNsN+F+sOD+R6m14feUyt
+jRYnfe0vvU70ct8gUAgVsHxkAqruca44SX5cY8djcKn4GHtwDa98rKJffBb4jnXA
+I36kVU5YmQ==
 -----END CERTIFICATE-----
diff --git a/cluster/clustercfg/clustercfg.py b/cluster/clustercfg/clustercfg.py
index c5f5c6c..0adef40 100644
--- a/cluster/clustercfg/clustercfg.py
+++ b/cluster/clustercfg/clustercfg.py
@@ -207,8 +207,8 @@
         ca_admitomatic.make_cert('admitomatic-webhook', ou='Admitomatic Webhook', hosts=['admitomatic.admitomatic.svc'])
 
     subprocess.check_call(["nix", "run",
-                           "-f", os.path.join(local_root, "cluster/nix/default.nix"),
-                           "provision",
+                           "-f", local_root,
+                           "cluster.nix.provision",
                            "-c", "provision-{}".format(fqdn.split('.')[0])])
 
 
diff --git a/cluster/doc/admin.md b/cluster/doc/admin.md
index 1dfb50a..097e749 100644
--- a/cluster/doc/admin.md
+++ b/cluster/doc/admin.md
@@ -1,31 +1,8 @@
-HSCloud Clusters
-================
-
-Admin documentation. For user documentation, see [//cluster/doc/user.md](/cluster/doc/user.md).
+Cluster Admin Docs
+==================
 
 Current cluster: `k0.hswaw.net`
 
-Persistent Storage (waw2)
--------------------------
-
-HDDs on bc01n0{1-3}. 3TB total capacity. Don't use this as this pool should go
-away soon (the disks are slow, the network is slow and the RAID controllers
-lie). Use ceph-waw3 instead.
-
-The following storage classes use this cluster:
-
- - `waw-hdd-paranoid-1` - 3 replicas
- - `waw-hdd-redundant-1` - erasure coded 2.1
- - `waw-hdd-yolo-1` - unreplicated (you _will_ lose your data)
- - `waw-hdd-redundant-1-object` - erasure coded 2.1 object store
-
-Rados Gateway (S3) is available at https://object.ceph-waw2.hswaw.net/. To
-create a user, ask an admin.
-
-PersistentVolumes currently bound to PersistentVolumeClaims get automatically
-backed up (hourly for the next 48 hours, then once every 4 weeks, then once
-every month for a year).
-
 Persistent Storage (waw3)
 -------------------------
 
diff --git a/cluster/doc/index.md b/cluster/doc/index.md
deleted file mode 100644
index afd04e4..0000000
--- a/cluster/doc/index.md
+++ /dev/null
@@ -1,6 +0,0 @@
-Warsaw Hackerspace Kubernetes Cluster
-=====================================
-
-**User documentation**: [user.md](user.md).
-
-**Admin documentation**: [admin.md](admin.md).
diff --git a/cluster/doc/site.html b/cluster/doc/site.html
new file mode 100644
index 0000000..22a1bb8
--- /dev/null
+++ b/cluster/doc/site.html
@@ -0,0 +1,13 @@
+{{ define "header" }}
+<span class="red">hackdoc://cluster</span>
+<span>Hackerspace Cluster Docs</span>
+{{ end }}
+
+{{ define "topbar" }}
+<span><a href="/cluster/">Home</a></span>
+<span><a href="/cluster/doc/user.md">User Docs</a></span>
+<span><a href="/cluster/doc/admin.md">Admin Docs</a></span>
+{{ end }}
+
+{{ define "sidebar" }}
+{{ end }}
diff --git a/cluster/doc/user.md b/cluster/doc/user.md
index 6dd6938..f04e7db 100644
--- a/cluster/doc/user.md
+++ b/cluster/doc/user.md
@@ -1,5 +1,5 @@
-Warsaw Hackerspace Kubernetes Clusters
-======================================
+Cluster User Docs
+=================
 
 End-user^Whacker documentation.
 
diff --git a/cluster/hackdoc.toml b/cluster/hackdoc.toml
new file mode 100644
index 0000000..af7f8b8
--- /dev/null
+++ b/cluster/hackdoc.toml
@@ -0,0 +1,5 @@
+[template.default]
+sources = [
+    "//devtools/hackdoc/tpl/base.html",
+    "//cluster/doc/site.html",
+]
diff --git a/cluster/kube/k0-nginx-ingress-controller.jsonnet b/cluster/kube/k0-nginx-ingress-controller.jsonnet
new file mode 100644
index 0000000..a3a608e
--- /dev/null
+++ b/cluster/kube/k0-nginx-ingress-controller.jsonnet
@@ -0,0 +1,7 @@
+// Only the NGINX Ingress Controller.
+
+local k0 = (import "k0.libsonnet").k0;
+
+{
+    nginx: k0.cluster.nginx,
+}
diff --git a/cluster/kube/k0.libsonnet b/cluster/kube/k0.libsonnet
index 8d7d49f..57d39d0 100644
--- a/cluster/kube/k0.libsonnet
+++ b/cluster/kube/k0.libsonnet
@@ -83,6 +83,7 @@
                 herpDev: k0.cockroach.waw2.Client("herp-dev"),
                 gitea: k0.cockroach.waw2.Client("gitea"),
                 issues: k0.cockroach.waw2.Client("issues"),
+                dns: k0.cockroach.waw2.Client("dns"),
             },
         },
 
@@ -97,6 +98,19 @@
                         count: 1,
                         allowMultiplePerNode: false,
                     },
+                    resources: {
+                        osd: {
+                            requests: {
+                                cpu: "2",
+                                memory: "6G",
+                            },
+                            limits: {
+                                cpu: "2",
+                                memory: "8G",
+                            },
+                        },
+
+                    },
                     storage: {
                         useAllNodes: false,
                         useAllDevices: false,
@@ -342,6 +356,7 @@
                         { namespace: "gerrit", dns: "gerrit.hackerspace.pl" },
                         { namespace: "gitea-prod", dns: "gitea.hackerspace.pl" },
                         { namespace: "hswaw-prod", dns: "*.hackerspace.pl" },
+                        { namespace: "hswaw-prod", dns: "*.hswaw.net" },
                         { namespace: "internet", dns: "internet.hackerspace.pl" },
                         { namespace: "matrix", dns: "matrix.hackerspace.pl" },
                         { namespace: "onlyoffice-prod", dns: "office.hackerspace.pl" },
diff --git a/cluster/kube/lib/cockroachdb.libsonnet b/cluster/kube/lib/cockroachdb.libsonnet
index 9f206f0..2486570 100644
--- a/cluster/kube/lib/cockroachdb.libsonnet
+++ b/cluster/kube/lib/cockroachdb.libsonnet
@@ -334,7 +334,7 @@
                                 command: [
                                     "/bin/bash",
                                     "-ecx",
-                                    "/cockroach/cockroach init --host=%s.cluster.local:%d" % [cluster.servers[0].service.host, cluster.cfg.portServe],
+                                    "/cockroach/cockroach init --host=%s.cluster.local:%d || true" % [cluster.servers[0].service.host, cluster.cfg.portServe],
                                 ],
                                 volumeMounts: [
                                     {
diff --git a/cluster/kube/lib/nginx-ingress-controller/Dockerfile b/cluster/kube/lib/nginx-ingress-controller/Dockerfile
new file mode 100644
index 0000000..9555387
--- /dev/null
+++ b/cluster/kube/lib/nginx-ingress-controller/Dockerfile
@@ -0,0 +1,7 @@
+# Temporary bump up to openssl 1.1.1k.
+# TODO(q3k): remove this once 1.1.1k lands in upstream n-i-c.
+
+FROM k8s.gcr.io/ingress-nginx/controller:v0.44.0@sha256:3dd0fac48073beaca2d67a78c746c7593f9c575168a17139a9955a82c63c4b9a
+USER root
+RUN apk update && apk upgrade
+USER www-data
diff --git a/cluster/kube/lib/nginx.libsonnet b/cluster/kube/lib/nginx.libsonnet
index 510f851..02422dc 100644
--- a/cluster/kube/lib/nginx.libsonnet
+++ b/cluster/kube/lib/nginx.libsonnet
@@ -8,7 +8,20 @@
         local env = self,
         local cfg = env.cfg,
         cfg:: {
-            image: "quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.23.0",
+            # Built from nginx-ingress-controller/Dockerfile:
+            #
+            #   $ cd cluster/kube/lib/nginx-ingress-controller
+            #   $ docker build -t eu.gcr.io/bgpwtf/nginx-ingress-controller:v0.44.0-r1 .
+            #   [..]
+            #   (2/8) Upgrading libcrypto1.1 (1.1.1i-r0 -> 1.1.1k-r0)
+            #   (3/8) Upgrading libssl1.1 (1.1.1i-r0 -> 1.1.1k-r0
+            #   [...]
+            #   (8/8) Upgrading openssl (1.1.1i-r0 -> 1.1.1k-r0)
+            #   $ docker push eu.gcr.io/bgpwtf/nginx-ingress-controller:v0.44.0-r1
+            #
+            # TODO(q3k): unfork this once openssl 1.1.1k lands in upstream
+            # nginx-ingress-controller.
+            image: "eu.gcr.io/bgpwtf/nginx-ingress-controller:v0.44.0-r1",
             namespace: "nginx-system",
         },
 
@@ -62,7 +75,7 @@
                     verbs: ["get", "list", "watch"],
                 },
                 {
-                    apiGroups: ["extensions"],
+                    apiGroups: ["extensions", "networking.k8s.io"],
                     resources: ["ingresses"],
                     verbs: ["get", "list", "watch"],
                 },
@@ -72,10 +85,15 @@
                     verbs: ["create", "patch"],
                 },
                 {
-                    apiGroups: ["extensions"],
+                    apiGroups: ["extensions", "networking.k8s.io"],
                     resources: ["ingresses/status"],
                     verbs: ["update"],
                 },
+                {
+                    apiGroups: ["extensions", "networking.k8s.io"],
+                    resources: ["ingressclasses"],
+                    verbs: ["get", "list", "watch"],
+                },
             ],
         },
 
@@ -102,11 +120,36 @@
             rules : [
                 {
                     apiGroups: [""],
-                    resources: ["configmaps", "pods", "secrets", "namespaces"],
+                    resources: ["namespaces"],
                     verbs: ["get"],
                 },
                 {
                     apiGroups: [""],
+                    resources: ["configmaps", "pods", "secrets", "endpoints"],
+                    verbs: ["get", "list", "watch"],
+                },
+                {
+                    apiGroups: [""],
+                    resources: ["services"],
+                    verbs: ["get", "list", "watch"],
+                },
+                {
+                    apiGroups: ["extensions", "networking.k8s.io"],
+                    resources: ["ingresses"],
+                    verbs: ["get", "list", "watch"],
+                },
+                {
+                    apiGroups: ["extensions", "networking.k8s.io"],
+                    resources: ["ingresses/status"],
+                    verbs: ["update"],
+                },
+                {
+                    apiGroups: ["extensions", "networking.k8s.io"],
+                    resources: ["ingressclasses"],
+                    verbs: ["get", "list", "watch"],
+                },
+                {
+                    apiGroups: [""],
                     resources: ["configmaps"],
                     resourceNames: ["ingress-controller-leader-nginx"],
                     verbs: ["get", "update"],
@@ -118,8 +161,8 @@
                 },
                 {
                     apiGroups: [""],
-                    resources: ["endpoints"],
-                    verbs: ["get"],
+                    resources: ["events"],
+                    verbs: ["create", "patch"],
                 },
             ],
         },
@@ -177,8 +220,18 @@
                         containers_: {
                             controller: kube.Container("nginx-ingress-controller") {
                                 image: cfg.image,
+                                imagePullPolicy: "IfNotPresent",
+                                lifecycle: {
+                                    preStop: {
+                                        exec: {
+                                            command: [ "/wait-shutdown" ],
+                                        },
+                                    },
+                                },
                                 args: [
                                     "/nginx-ingress-controller",
+                                    "--election-id=ingress-controller-leader",
+                                    "--ingress-class=nginx",
                                     "--configmap=%s/%s" % [cfg.namespace, env.maps.configuration.metadata.name],
                                     "--tcp-services-configmap=%s/%s" % [cfg.namespace, env.maps.tcp.metadata.name],
                                     "--udp-services-configmap=%s/%s" % [cfg.namespace, env.maps.udp.metadata.name],
@@ -222,7 +275,7 @@
                                         drop: ["ALL"],
                                         add: ["NET_BIND_SERVICE"],
                                     },
-                                    runAsUser: 33,
+                                    runAsUser: 101,
                                 },
                                 resources: {
                                     limits: { cpu: "2", memory: "4G" },
diff --git a/cluster/nix/default.nix b/cluster/nix/default.nix
deleted file mode 100644
index a5f5082..0000000
--- a/cluster/nix/default.nix
+++ /dev/null
@@ -1,14 +0,0 @@
-let
-  pkgs = import (fetchGit {
-    name = "nixos-unstable-2020-08-22";
-    url = https://github.com/nixos/nixpkgs-channels/;
-    rev = "c59ea8b8a0e7f927e7291c14ea6cd1bd3a16ff38";
-  });
-
-  cfg = {
-    overlays = [
-      (import ./provision.nix)
-    ];
-  };
-
-in pkgs cfg
diff --git a/cluster/nix/modules/containerd.toml b/cluster/nix/modules/containerd.toml
new file mode 100644
index 0000000..b079637
--- /dev/null
+++ b/cluster/nix/modules/containerd.toml
@@ -0,0 +1,134 @@
+version = 2
+root = "/var/lib/containerd"
+state = "/run/containerd"
+plugin_dir = ""
+disabled_plugins = []
+required_plugins = []
+oom_score = 0
+
+[grpc]
+  address = "/run/containerd/containerd.sock"
+  tcp_address = ""
+  tcp_tls_cert = ""
+  tcp_tls_key = ""
+  uid = 0
+  gid = 0
+  max_recv_message_size = 16777216
+  max_send_message_size = 16777216
+
+[ttrpc]
+  address = ""
+  uid = 0
+  gid = 0
+
+[debug]
+  address = ""
+  uid = 0
+  gid = 0
+  level = ""
+
+[metrics]
+  address = ""
+  grpc_histogram = false
+
+[cgroup]
+  path = ""
+
+[timeouts]
+  "io.containerd.timeout.shim.cleanup" = "5s"
+  "io.containerd.timeout.shim.load" = "5s"
+  "io.containerd.timeout.shim.shutdown" = "3s"
+  "io.containerd.timeout.task.state" = "2s"
+
+[plugins]
+  [plugins."io.containerd.gc.v1.scheduler"]
+    pause_threshold = 0.02
+    deletion_threshold = 0
+    mutation_threshold = 100
+    schedule_delay = "0s"
+    startup_delay = "100ms"
+  [plugins."io.containerd.grpc.v1.cri"]
+    disable_tcp_service = true
+    stream_server_address = "127.0.0.1"
+    stream_server_port = "0"
+    stream_idle_timeout = "4h0m0s"
+    enable_selinux = false
+    selinux_category_range = 1024
+    sandbox_image = "k8s.gcr.io/pause:3.2"
+    stats_collect_period = 10
+    systemd_cgroup = false
+    enable_tls_streaming = false
+    max_container_log_line_size = 16384
+    disable_cgroup = false
+    disable_apparmor = false
+    restrict_oom_score_adj = false
+    max_concurrent_downloads = 3
+    disable_proc_mount = false
+    unset_seccomp_profile = ""
+    tolerate_missing_hugetlb_controller = true
+    disable_hugetlb_controller = true
+    ignore_image_defined_volumes = false
+    [plugins."io.containerd.grpc.v1.cri".containerd]
+      snapshotter = "overlayfs"
+      default_runtime_name = "runc"
+      no_pivot = false
+      disable_snapshot_annotations = true
+      discard_unpacked_layers = false
+      [plugins."io.containerd.grpc.v1.cri".containerd.default_runtime]
+        runtime_type = ""
+        runtime_engine = ""
+        runtime_root = ""
+        privileged_without_host_devices = false
+        base_runtime_spec = ""
+      [plugins."io.containerd.grpc.v1.cri".containerd.untrusted_workload_runtime]
+        runtime_type = ""
+        runtime_engine = ""
+        runtime_root = ""
+        privileged_without_host_devices = false
+        base_runtime_spec = ""
+      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
+        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
+          runtime_type = "io.containerd.runc.v2"
+          runtime_engine = ""
+          runtime_root = ""
+          privileged_without_host_devices = false
+          base_runtime_spec = ""
+          [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
+            SystemdCgroup = true
+    [plugins."io.containerd.grpc.v1.cri".cni]
+      bin_dir = "/opt/cni/bin"
+      conf_dir = "/opt/cni/conf"
+      max_conf_num = 1
+      conf_template = ""
+    [plugins."io.containerd.grpc.v1.cri".registry]
+      [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
+        [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
+          endpoint = ["https://registry-1.docker.io"]
+    [plugins."io.containerd.grpc.v1.cri".image_decryption]
+      key_model = ""
+    [plugins."io.containerd.grpc.v1.cri".x509_key_pair_streaming]
+      tls_cert_file = ""
+      tls_key_file = ""
+  [plugins."io.containerd.internal.v1.opt"]
+    path = "/opt/containerd"
+  [plugins."io.containerd.internal.v1.restart"]
+    interval = "10s"
+  [plugins."io.containerd.metadata.v1.bolt"]
+    content_sharing_policy = "shared"
+  [plugins."io.containerd.monitor.v1.cgroups"]
+    no_prometheus = false
+  [plugins."io.containerd.runtime.v1.linux"]
+    shim = "containerd-shim"
+    runtime = "runc"
+    runtime_root = ""
+    no_shim = false
+    shim_debug = false
+  [plugins."io.containerd.runtime.v2.task"]
+    platforms = ["linux/amd64"]
+  [plugins."io.containerd.service.v1.diff-service"]
+    default = ["walking"]
+  [plugins."io.containerd.snapshotter.v1.devmapper"]
+    root_path = ""
+    pool_name = ""
+    base_image_size = ""
+    async_remove = false
diff --git a/cluster/nix/modules/kubelet.nix b/cluster/nix/modules/kubelet.nix
index f475b5b..1a71b48 100644
--- a/cluster/nix/modules/kubelet.nix
+++ b/cluster/nix/modules/kubelet.nix
@@ -16,7 +16,7 @@
     name = "pause";
     tag = "latest";
     contents = top.package.pause;
-    config.Cmd = "/bin/pause";
+    config.Cmd = ["/bin/pause"];
   };
 
   kubeconfig = top.lib.mkKubeConfig "kubelet" cfg.kubeconfig;
@@ -45,12 +45,6 @@
   taints = concatMapStringsSep "," (v: "${v.key}=${v.value}:${v.effect}") (mapAttrsToList (n: v: v) cfg.taints);
 in
 {
-  imports = [
-    #(mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "applyManifests" ] "")
-    #(mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "cadvisorPort" ] "")
-    #(mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "allowPrivileged" ] "")
-  ];
-
   # services/cluster/kubernetes/default.nix still wants to poke flannel,
   # but since we nuke that module we have to add a fake tunable for it.
   options.services.kubernetes.flannel = {
@@ -203,15 +197,57 @@
     (mkIf cfg.enable {
       services.kubernetes.kubelet.seedDockerImages = [infraContainer];
 
+      # Drop crictl into administrative command line.
+      environment.systemPackages = with pkgs; [ cri-tools ];
+
+      # Force disable Docker.
+      virtualisation.docker.enable = false;
+
+      # TODO(q3k): move to unified cgroups (cgroup v2) once we upgrade to
+      # Kubelet 1.19.
+      systemd.enableUnifiedCgroupHierarchy = false;
+
+      # Run containerd service. This is exposes the CRI API that is consumed by
+      # crictl and Kubelet.
+      systemd.services.containerd = {
+        description = "containerd container runtime";
+        wantedBy = [ "kubernetes.target" ];
+        after = [ "network.target" ];
+        path = with pkgs; [ runc iptables ];
+        serviceConfig = {
+          Delegate = "yes";
+          KillMode = "process";
+          Restart = "always";
+          RestartSec = "5";
+          LimitNPROC = "infinity";
+          LimitCORE = "infinity";
+          # https://github.com/coreos/fedora-coreos-tracker/issues/329
+          LimitNOFILE = "1048576";
+          TasksMax = "infinity";
+          OOMScoreAdjust = "-999";
+
+          ExecStart = "${pkgs.containerd}/bin/containerd -c ${./containerd.toml}";
+        };
+      };
+
       systemd.services.kubelet = {
         description = "Kubernetes Kubelet Service";
         wantedBy = [ "kubernetes.target" ];
-        after = [ "network.target" "docker.service" "kube-apiserver.service" ];
-        path = with pkgs; [ gitMinimal openssh docker utillinux iproute ethtool thin-provisioning-tools iptables socat ] ++ top.path;
+        after = [ "network.target" "containerd.service" "kube-apiserver.service" ];
+        path = with pkgs; [ gitMinimal openssh utillinux iproute ethtool thin-provisioning-tools iptables socat cri-tools containerd gzip ] ++ top.path;
+
+        # Mildly hacky - by moving over to OCI image build infrastructure in
+        # NixOS we should be able to get rid of the gunzip.
+        # TODO(q3k): figure this out, check if this is even being used by
+        # kubelet.
         preStart = ''
           ${concatMapStrings (img: ''
-            echo "Seeding docker image: ${img}"
-            docker load <${img}
+            echo "Seeding OCI image: ${img}"
+            cp ${img} /tmp/image.tar.gz
+            rm -f /tmp/image.tar
+            gunzip /tmp/image.tar.gz
+            ctr -n=k8s.io images import /tmp/image.tar || true
+            rm /tmp/image.tar
           '') cfg.seedDockerImages}
         '';
         serviceConfig = {
@@ -221,6 +257,9 @@
           Restart = "on-failure";
           RestartSec = "1000ms";
           ExecStart = ''${cfg.package}/bin/kubelet \
+            --cgroup-driver=systemd \
+            --container-runtime=remote \
+            --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \
             --address=${cfg.address} \
             --authentication-token-webhook \
             --authentication-token-webhook-cache-ttl="10s" \
@@ -263,7 +302,8 @@
         };
       };
 
-      boot.kernelModules = ["br_netfilter"];
+      boot.kernelModules = [ "br_netfilter" "overlay" ];
+      boot.kernel.sysctl."net.ipv4.ip_forward" = "1";
 
       services.kubernetes.kubelet.hostname = with config.networking;
         mkDefault (hostName + optionalString (domain != null) ".${domain}");
diff --git a/cluster/nix/modules/kubernetes.nix b/cluster/nix/modules/kubernetes.nix
index 92e28de..df82eff 100644
--- a/cluster/nix/modules/kubernetes.nix
+++ b/cluster/nix/modules/kubernetes.nix
@@ -30,24 +30,6 @@
       ./kubelet.nix
     ];
 
-  # List services that you want to enable:
-  virtualisation.docker.enable = true;
-  virtualisation.docker.extraOptions = "--iptables=false --ip-masq=false --ip-forward=true";
-
-  # Docker 1.13 sets iptables FORWARD to DROP. Unfuck this.
-  systemd.services."docker-iptables-unfuck" = {
-    enable = true;
-    wantedBy = [ "kubernetes.target" ];
-    description = "Docker iptable Unfuck";
-    after = [ "docker.service" ];
-    requires = [ "docker.service" ];
-    path = [ pkgs.iptables ];
-    script = ''
-      iptables -P FORWARD ACCEPT
-    '';
-    serviceConfig.Type = "oneshot";
-  };
-
   networking.firewall.enable = false;
 
   # Point k8s apiserver address at ourselves, as every machine runs an apiserver with this cert name.
@@ -55,23 +37,6 @@
     127.0.0.1 ${k8sapi}
   '';
 
-  security.acme.acceptTerms = true;
-  security.acme.certs = {
-    host = {
-      email = acmeEmail;
-      domain = fqdn;
-      webroot = services.nginx.virtualHosts.host.root;
-    };
-  };
-
-  services.nginx = {
-    enable = true;
-    virtualHosts.host = {
-      serverName = fqdn;
-      root = "/var/www/${fqdn}";
-    };
-  };
-
   services.etcd = rec {
     enable = true;
     name = fqdn;
diff --git a/cluster/nix/provision.nix b/cluster/nix/provision.nix
index 20ed0a0..7ab7e71 100644
--- a/cluster/nix/provision.nix
+++ b/cluster/nix/provision.nix
@@ -1,10 +1,12 @@
-self: super:
+{ hscloud, pkgs, ... }:
+
+with builtins;
 
 let 
   machines = (import ./defs-machines.nix);
   configurations = builtins.listToAttrs (map (machine: {
     name = machine.fqdn;
-    value = super.nixos ({ config, pkgs, ... }: {
+    value = pkgs.nixos ({ config, pkgs, ... }: {
       networking.hostName = machine.name;
       imports = [
         ./modules/base.nix
@@ -32,18 +34,16 @@
    fi
   '';
 
-  machineProvisioners = builtins.listToAttrs (map (machine: {
-      name = "provision-${machine.name}";
-      value = super.writeScriptBin "provision-${machine.name}" (scriptForMachine machine);
-    }) machines);
+  provisioners = (map (machine:
+    pkgs.writeScriptBin "provision-${machine.name}" (scriptForMachine machine)
+  ) machines);
+
+  provision = pkgs.writeScriptBin "provision" (
+    ''
+      echo "Available provisioniers:"
+    '' + (concatStringsSep "\n" (map (machine: "echo '  provision-${machine.name}'") machines)));
 in
-{
-  provision = ({
-    provision = super.writeScriptBin "provision"
-      (
-        ''
-          echo "Available provisioniers:"
-        '' + (builtins.concatStringsSep "\n" (map (machine: "echo '  provision-${machine.name}'") machines))
-      );
-  }) // machineProvisioners;
+pkgs.symlinkJoin {
+  name = "provision";
+  paths = [ provision ] ++ provisioners;
 }
diff --git a/cluster/secrets/cipher/etcd-bc01n03.hswaw.net.key b/cluster/secrets/cipher/etcd-bc01n03.hswaw.net.key
deleted file mode 100644
index 84be5a5..0000000
--- a/cluster/secrets/cipher/etcd-bc01n03.hswaw.net.key
+++ /dev/null
@@ -1,91 +0,0 @@
------BEGIN PGP MESSAGE-----
-
-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
-=sxdX
------END PGP MESSAGE-----
diff --git a/cluster/secrets/cipher/etcdpeer-bc01n03.hswaw.net.key b/cluster/secrets/cipher/etcdpeer-bc01n03.hswaw.net.key
deleted file mode 100644
index fb0ef5b..0000000
--- a/cluster/secrets/cipher/etcdpeer-bc01n03.hswaw.net.key
+++ /dev/null
@@ -1,91 +0,0 @@
------BEGIN PGP MESSAGE-----
-
-hQEMAzhuiT4RC8VbAQf8CBxZQjF5n1dPLEungefSLRcjxskWRMK8YLDmVktoPws/
-N7x17wGxgGHlL12IWXKtHW6UUZ8qDGQdhTS1aF2kJm1B04YWDpxU+KX8Q2S6I0gf
-AJjFW6sbI/NyUo2ssrvDr8nUcPsFEX7EGXgCVdFXqMUc3FcKYGqwAmU+b9Nc7MKE
-vJj1mzZe9syROI6sif1tpNZMvuN50mPRNCr8htLp+LywS2ltwcroVQrGEaGs8Vy3
-4w2cMlXfZybe91h+aV3W/I55DLZaY21Ef7duEy4Y2HkPWJ9wO+FdDwo+4GhCDrGp
-2bet6yZI+euiTnM/ip71lCYEFhL/2x8CTo56JW2lbIUBDANcG2tp6fXqvgEIAIyP
-of2fqEoyEAPuH0iNq3NK6fCEX47hlWJRYL1RwK39X+HbwD+nYlq0DVPlKODsWIIh
-hMhg133sY1cmyaO/V74Tqvt9ILdyu3jk6r3mmkk8UfbJNBtwq8KsZZUxx3XpfL5j
-y9NimCqO105s/0Uo2sKE0kSCmPwxxLsQ9ab2mNGYQwSsbA4IBHCp/FQ142wE243C
-rbrJif6zRn//ZPKzFeoOiwjY6LnKKAOe4AE5bdEyVsQTjII1ALPqEb/Ijviui+1P
-CdIHh9GIYldaCXxKgc4o/tan6RIbBNFmJfrszl0wKH9tFH4W3+m+Zjm/kBoyruRK
-FumgOPzHXFKKTFr5ugOFAgwDodoT8VqRl4UBD/9vhd06eM1ZlhJqX4HXysjwDFam
-oLGg4pZeKBpoaEsoqQzQmoR9gModN4wAl59j2b09C22T3TdskJN1xgYlI433BeEF
-A0cVGefBj30bYUCpzbzHC0LV//W8n/SsdVHuTzWCQ8HIEi4cyb//qztRRrkWsht9
-7hM58ML4p5utsWw4dG3OWGXFzqabyTAWEY9kS1Y9kkTa5CpJvV419CldqZBvSZwp
-BpiJgonX5psBDsaX8iNjsmRmMbZRnzWf0nOrRlTWEovLMysp+8/soaAAFMAzP67I
-xTCSrelbdbiZ9Oy+dspfLMr5m2xHIBN7hWDybgOb1v3b49zgywUMjbZFcO/eoq7u
-0Cu2qaqs2plzWiZMD85Xlc/aAIClI3dsJU5gNVZzAiXnXmXY0nBLTP1sZZQ7YDqs
-+a4WHqDx+EgLXC0Xv5w7xtx5wN2Vl1NT0DqMw4IdEDUJYaZPOEJ8Iyvkv4dXGTPl
-RGlyE2GANywCRHC0YoXwIY21F/Ehw+TUNxtQ6YTVNlVAdGVTIcduKjPYCow3x0/z
-wl5BL92Cp1e+XUOk15AgxtJi0akKp0kzU9Y/yOWaTr/oxNo7xHQCu9LWKENmvmcE
-L7Ls7R4EvF+EPAtoLix7JqYsJKyNN3mHH3yXdfuZ6HzpL6xnAJ3oCDADDWU9b6+M
-X1q2UpHZJ4mo14e3KYUCDAPiA8lOXOuz7wEP/2iGzYGHfR9ktkBdnjXBHpvkA3Uv
-0QNfkiWdnD/edJ08KfDtEEgjx348WFCzqXgZV8NMGmeCn7fykXt0A3J0fBeHpZuI
-6not2Yf0FyfXI/DB4/gYiIvtXRc/XhT/tiVbqgsIhKqN0VN2UH6xMDaxKUXqD52g
-KX+uFjLQwjybNRdxq4dsN3ZjWb5tfVsRN4feUwLQm86mZlxDaWzYLVnK3IF4tX4H
-pCutwSlxebdf0MurJCFKltIRKLmhUW4inkRR42VUHuULp80BxAMv49XlixX7agnC
-MKb7p1cTsEcTpQLnbGRb5w/WVJKk3H7tD1IChQKLZl8PhKZBHKN50LXKBD1oJmSi
-HjxuK1WwfDeQ5wn2hTuWS4Kf3hcpu0MSxAjTDS5WnrOjWW4Yf9iG97SkOkZRPfbt
-ZtHPn3MexECXtYg2laB9GG0+wHcW12mQfq0WrbaQFecng7K0nLZF6EdTqletJeYX
-FUpd6DZ9Muf8/bUZW0/QaO5DaN+MibYqgyzDn5ZdaFfsN8fikIlz2FRbxwivmMCI
-Rg1Wj9dmhIQCT3BiOSGrPqWhHhEolXlKSU9JKlOQBh6ANti83suEVmquQSYNQhA8
-S7Ucvlf2oHVxZfaV0xBUhMKTrA8mnapniyh9TnTaSzPg1gHzgILpHTkv0UHtOiGP
-JTBQixAzPs75zYMu0usBK9WMaeTryvpx/6I4Ljwerl6l6+kDwYF1uHbMOv7BIIRg
-nDrxyO85pepSzKMGw/VxG4K9qc21juJGK+GbHFgFLD7XAgRVZ0+UqH7cgBDA7V+Z
-fHDUN3ykjHv3vPWmrM5ujoIg57j/OdzRL+pZuSDVWQYo7GuAJ938LVsc6QR5LjIK
-fohaAgfyo2oR4+jA9EzuPgTvQpDRVm82ysOgUZm56yU/4gYSLdnDTeQ1f1nR1tdf
-/rAedZjcO2bmlIS+iAgGa8yDibLZqSaU181Oeozeilv5ec3DHTy0dO82qFokeadq
-1EqquPLRqL3Um7ZrFfgMWewpNFNUKODpdG0ZsIU9fkF0toPiiax+chbZoIpytOKt
-h27Sd73URh8izK+OxGUvSbytkudIe8eCDIvhm828SNJj9S2tnGDlhNa0DDRzeOFL
-/xql6RDwEJZlfvGMd4JPYrTvs9pa+z4lg1yiADsft8uQs2n6CvyxF+NkfoqMN7oQ
-W0vxOCdExZZZz81AYHG6SaxtoYGjNftvzIo7+gRyUmoky+6JLisfqfFwKffbU+Fs
-4QaKzR3ptWAYZ/9I113lMKt5wSmiwcWOLaCUmtOEQScEz9S5+ebzo2grMQsM4Skd
-epK3lXe/VDWtyGeWmyC7eT//fgWJr+51GF847I/e7mLNDRGflY/I/g1IWNJjPN7V
-ElftOw4o/nvWopoIEY+Z1HOwv/jLYKNkeCmE2nCPFxTd6KZa3NvhXyUClEmQArz6
-S2Qq/h3DfCFLi9pXQPgLb1KPRBBw++YTf1EnmhYse3Kzl6tRL/wphMF12uhyLBui
-0oTdam6/8Oo0ENxfPEns8k4CVmM8AaD7/vhMTvMTN2oTreEUqiZaqJk5KjX1GSCM
-uywJ/mFImqTLdDFNVPJkY59AYnv/5d6qIjZRaJgCRjqyASghC1qpeTep3op5wojC
-SFyQ5gu06srFooVJ8uZmib6oPTn/jTQKpwcOT3xAJV3P4QPe7z30VvjEXV9LmL6e
-Md09ZKsRFK24gop8LGoGK5r/33VpelwFdmoKanUzfWqlTN116VVtPbPwVJM4UslJ
-C/yaXwl2nfO323z6Xi8q5h7E1WpLrhkRqwajwOdK0hW5ZYj3VnLiTBOsmokHrKet
-J6kITgml5GPHn90US8PmSP4sgR1UP1902L2IMcDRj8lHzZHOf41N0lCxlsrMTBQk
-ggRmhbHGfoqoHq1JQb6AMhe3nQyzWC0MBU7l63FyVQttMzSI51h2BhmzSodN+wwM
-bjWV+ilineQuoaChAaWmcDNFXKnK61MmHk8B/urSNLAaBfFSgZF6Qovrupy8z94r
-k4c1MALC2w35deYNWDbZzjb0AikkwG0hCFDifXtQx/Rvdo/aPYTHCDAyucL1coIr
-rb2CzcByFB/A55S6iWIxwxCbAHWNtmphWPk5gdfC6XXUrYaINyAeOuavJ/N7LUbW
-6U1BlyAtsyLt9o6K+txzwCtgY/ZqS6Et8OecdrwonYm0LNbSQa7f1eYWrv8fzlQO
-fMhFfzG9f81Q9J2XnCYE1v+GV/UjcY9MidteQdneYPRyG1aRY1GUkpq53UkVpllm
-EzHrYfAfiyJzOOQEzudL15kB7V6NIqkx41FMx8Qh/mfIVRXSaPEw27aomVedaLC2
-J3Y7T957VjVw5nW9Clq5SyivOLccyzdb+2ikWaWuogp7gU81POgOm4E35x9vYxu/
-2dqtjNjxJC1ZqKSKcBWtXInwQiYYhnlDe9C5tWuJ6mn9zguP+5/fjhFsH/8pn/yu
-JcqTwU6shDSPDFEfw6cZq3wXCBLKH/XRqIcxAbCHAhe5TJgRJc0lN3VtqDU/GYqi
-hgi/MqjwCx4kzimbDY/RJT6rTWaxi1QGVazmryuZiRkKWWZaltVL1b9qKElFYlK6
-WA/0AbJDBoklwYyWgWYl++NlcK+LevhYoeK359EXIBQ+Xj6Up0JkZXYjYcaNuD1B
-o4DXCjBql4mISoyqfNChl151utRlfkHEoTWYc74YNHByzMFiwU2QyUob/QTLJ483
-+Z847nTRNeaCXiBr7BllzNx8FN+HQ411tVW7gTsgpshkXHKsK70bSKAlk4LjTAmG
-K07MbFZlrducA2bD42vz4DaoylV7aU+++dV9wETh669vmc0V+cRPt40CBf4pjQ57
-swpyZUUmj3eJ2bnIh1GyHT2Qek6fOATeaAYHOKVm6+zT6vAERc8OSld/xVpWwmwv
-xeM8eBwkALgdtcERXCQ0mFCmRP+zLdoCPCGjGV9OZlGairaPcpNKyGimeQ6WSWQp
-9qhmo8M8MhMRCKY3z9c4VWof7YAs6DlC6pwpb3vgRXYI6fPTAx+VhOEZhTSSEJcF
-b+k/NyteXK4D3FYBxrhQreAvr8os3d77nbGhaDf02KfldGPDfcDoCwcOdTTcaYcm
-1d5tKaAVDmf0a37iXgO+rXpxL0nimkBSY/WRw1OvXB9S+liDTgZkPnCrLpk8YUz8
-eizLFaT/Yk2iLoSvR49yXjPK7rYvZHoCkTdY2M/Wq2lS7hoJd9bPHjcBV6C9W64h
-vnY+xn6ZMY2zfl/abiJZvSvjbzjPkZi0SaxInQyOYT6PTK4lwwCjUbZ5hliY7g81
-hOV6uzD+HJpRd/tv5M3dbEnLs+6uEngNRraovuO5TAPO3oYt1Kk+tuZk39nn6N24
-h5sdaKYforDTamJ/TPvE3a9StCQCyrNnnBNd8WVE/cKk/8VnQyGKH9+MojzElOlh
-VnnJ6Dm4nytRnJQlh+pznkvFU4F5dLOoWk4WGPks8lbZOtt612zxSNjREZH4PQQF
-C3GG2oIdJawOJ0XolVyncOE5X1NRXz2FyEYhYWrLEcpuyJxxweU8h0/ZxZkvhiA6
-+afLkCo7MbBrt0hyQCaAVIz+b4Kr0LDARza5lO33Q9iNLNtGCx/zbyXc9f/UXbdW
-dtMaNC+9NqRPyVBfPcJXKIVaC4j8go/H9R8DO3Fa+ERfEQwQCQ9WYWBGqYMkpMSx
-a9N529e+aIzWxxBwUH6bTitIJi74UaTd4Ae4/s+WvCqmc1iGGDSSqUt72uJMcuP9
-Zd9/Ey0UtguBD4vmf2KYRhYxfhEpNdQpzh1kxFtOmFIuVSKtNh61CkDTXf0vl5Yq
-83gdfHEPkmLquVF38i/PJLZR9Qau7SzE6FT+EkaQ8SqLTmJ/iM1NNLhGfE5FIeqb
-IaWg9LEOEW8zQC9Ho9ezxh9OVlxkJMRfJFemz4oQ+8G4A4yFcCUzUAHMNJU59kD4
-oXbPTkOJdMfroJRyAQMqJHi3bI/7RbR7Ry2SdBYPOs3cA1LuZpzrVl+QaEIpjUvw
-YTkFW4LmEGEI76ejUE6Ro1ZFxorOg7Fc/XWukx/Sk7Pf0XvA5pRWxNBZbeaUbTPf
-xdsjZvavZw42YbUgy3eiPw13stTR33Z9u8/CduidOwipK6Fnwgse4w==
-=KgJ/
------END PGP MESSAGE-----
diff --git a/cluster/secrets/cipher/kube-kubelet-bc01n03.hswaw.net.key b/cluster/secrets/cipher/kube-kubelet-bc01n03.hswaw.net.key
deleted file mode 100644
index 936ecc4..0000000
--- a/cluster/secrets/cipher/kube-kubelet-bc01n03.hswaw.net.key
+++ /dev/null
@@ -1,91 +0,0 @@
------BEGIN PGP MESSAGE-----
-
-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
-=Q5eO
------END PGP MESSAGE-----
diff --git a/cluster/tools/BUILD b/cluster/tools/BUILD
index 141fff7..2526fd7 100644
--- a/cluster/tools/BUILD
+++ b/cluster/tools/BUILD
@@ -32,12 +32,6 @@
 )
 
 sh_binary(
-    name = "nixops",
-    srcs = ["nixops.sh"],
-    data = ["@nixops//:bin", "//tools:secretstore"],
-)
-
-sh_binary(
     name = "rook-s3cmd-config",
     srcs = ["rook-s3cmd-config.sh"],
     data = [
diff --git a/dc/README.md b/dc/README.md
index 6ee4c21..cf4287a 100644
--- a/dc/README.md
+++ b/dc/README.md
@@ -1,4 +1,16 @@
-hscloud/dc
-==========
+DC Docs Home
+============
 
-Software and systems related to DC operations and provisioning.
+Software, systems and hardware related to DC operations and provisioning.
+
+Software
+--------
+
+ - [arista-proxy](arista-proxy/), a gRPC proxy to Arista's JSON-RPC.
+ - [cmc-proxy](cmc-proxy/), a gRPC proxy to the Dell M1000E Chassic Management Controller.
+ - [m6220-proxy](m6220-proxy/), a gRPC proxy to the Dell M6220 switch.
+
+Hardware
+--------
+
+ - [hbj11](hbj11/), a JBOD SATA passthrough card for Dell M610 servers.
diff --git a/dc/arista-proxy/README.md b/dc/arista-proxy/README.md
index 60368dc..56e2979 100644
--- a/dc/arista-proxy/README.md
+++ b/dc/arista-proxy/README.md
@@ -5,40 +5,15 @@
 
 The schema is supposed to be 1:1 mapped to the JSON-RPC EAPI. This is just a dumb proxy.
 
-Getting and Building
---------------------
+Building
+--------
 
-    go get -d -u code.hackerspace.pl/q3k/arista-proxy
-    go generate code.hackerspace.pl/q3k/arista-proxy/proto
-    go build code.hackerspace.pl/q3k/arista-proxy
-
-Debug Status Page
------------------
-
-The `debug_address` flag controls spawning an HTTP server useful for debugging. You can use it to inspect gRPC request and view general status information of the proxy.
+    $ bazel build //dc/arista-proxy
 
 Flags
 -----
 
-    ./arista-proxy -help
-    Usage of ./arista-proxy:
-      -alsologtostderr
-        	log to standard error as well as files
       -arista_api string
         	Arista remote endpoint (default "http://admin:password@1.2.3.4:80/command-api")
-      -debug_address string
-        	Debug HTTP listen address, or empty to disable (default "127.0.0.1:42000")
-      -listen_address string
-        	gRPC listen address (default "127.0.0.1:43001")
-      -log_backtrace_at value
-        	when logging hits line file:N, emit a stack trace
-      -log_dir string
-        	If non-empty, write log files in this directory
-      -logtostderr
-        	log to standard error instead of files
-      -stderrthreshold value
-        	logs at or above this threshold go to stderr
-      -v value
-        	log level for V logs
-      -vmodule value
-        	comma-separated list of pattern=N settings for file-filtered logging
+
+For standard flags (eg. listen, logs, ...) run with -help.
diff --git a/dc/doc/site.html b/dc/doc/site.html
new file mode 100644
index 0000000..cce3767
--- /dev/null
+++ b/dc/doc/site.html
@@ -0,0 +1,12 @@
+{{ define "header" }}
+<span class="red">hackdoc://dc</span>
+<span>Hackerspace Datacenter Docs</span>
+{{ end }}
+
+{{ define "topbar" }}
+<span><a href="/dc/">Home</a></span>
+<span><a href="/dc/hbj11">HBJ11</a></span>
+{{ end }}
+
+{{ define "sidebar" }}
+{{ end }}
diff --git a/dc/hackdoc.toml b/dc/hackdoc.toml
new file mode 100644
index 0000000..81eb7c5
--- /dev/null
+++ b/dc/hackdoc.toml
@@ -0,0 +1,5 @@
+[template.default]
+sources = [
+    "//devtools/hackdoc/tpl/base.html",
+    "//dc/doc/site.html",
+]
diff --git a/dc/hbj11/README.md b/dc/hbj11/README.md
new file mode 100644
index 0000000..9649da1
--- /dev/null
+++ b/dc/hbj11/README.md
@@ -0,0 +1,16 @@
+hbj11 - the Hackerspace Blade JBOD adapter
+===
+
+Replacement cards for M610 blades at the hackerspace.
+
+![](doc/hbj11-a0-photo.jpg)
+
+Hardware
+--------
+
+To be published.
+
+EEPROM & flasher
+----------------
+
+See [flasher](flasher) for a WebUSB/STM32 based flasher for the cards.
diff --git a/dc/hbj11/doc/bluepill.jpg b/dc/hbj11/doc/bluepill.jpg
new file mode 100644
index 0000000..23667b3
--- /dev/null
+++ b/dc/hbj11/doc/bluepill.jpg
Binary files differ
diff --git a/dc/hbj11/doc/hbj11-a0-photo.jpg b/dc/hbj11/doc/hbj11-a0-photo.jpg
new file mode 100644
index 0000000..7ff08a9
--- /dev/null
+++ b/dc/hbj11/doc/hbj11-a0-photo.jpg
Binary files differ
diff --git a/dc/hbj11/doc/webi2c.png b/dc/hbj11/doc/webi2c.png
new file mode 100644
index 0000000..3a3b020
--- /dev/null
+++ b/dc/hbj11/doc/webi2c.png
Binary files differ
diff --git a/dc/hbj11/flasher/README.md b/dc/hbj11/flasher/README.md
new file mode 100644
index 0000000..ae9f8b5
--- /dev/null
+++ b/dc/hbj11/flasher/README.md
@@ -0,0 +1,20 @@
+HBJ11 Flasher
+=============
+
+This is a EEPROM flashing tool for the HBJ11 cards designed at the Warsaw Hackerspace.
+
+It's made up of two parts:
+
+The Device (Bluepill)
+---------------------
+
+An USB/I2C adapter based on an STM32 Bluepill devboard. See [bluepill](bluepill/) for more information. You will need one physically plugged into your machine and wired up to a PCIe socket to insert the HBJ11s into.
+
+![](../doc/bluepill.jpg)
+
+The Web Interface (WebI2C)
+---------------------------
+
+A WebUSB-based flashing tool that will run under any Chromium-based browser (eg. Chrome, Edge. See [web](web/) for more information or [start WebI2C now](web/index.html).
+
+![](../doc/webi2c.png)
diff --git a/dc/hbj11/flasher/bluepill/.cargo/config b/dc/hbj11/flasher/bluepill/.cargo/config
new file mode 100644
index 0000000..128b9c4
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/.cargo/config
@@ -0,0 +1,3 @@
+[build]
+target = "thumbv7m-none-eabi"
+rustflags = [ "-C", "link-arg=-Tlink.x", "-C", "inline-threshold=255"]
diff --git a/dc/hbj11/flasher/bluepill/.gitignore b/dc/hbj11/flasher/bluepill/.gitignore
new file mode 100644
index 0000000..eb5a316
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/.gitignore
@@ -0,0 +1 @@
+target
diff --git a/dc/hbj11/flasher/bluepill/Cargo.lock b/dc/hbj11/flasher/bluepill/Cargo.lock
new file mode 100644
index 0000000..ff7488c
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/Cargo.lock
@@ -0,0 +1,460 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+[[package]]
+name = "aligned"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c19796bd8d477f1a9d4ac2465b464a8b1359474f06a96bb3cda650b4fca309bf"
+dependencies = [
+ "as-slice",
+]
+
+[[package]]
+name = "as-slice"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4d1c23475b74e3672afa8c2be22040b8b7783ad9b461021144ed10a46bb0e6"
+dependencies = [
+ "generic-array 0.12.3",
+ "generic-array 0.13.2",
+ "generic-array 0.14.4",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
+
+[[package]]
+name = "bare-metal"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3"
+dependencies = [
+ "rustc_version",
+]
+
+[[package]]
+name = "bitfield"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719"
+
+[[package]]
+name = "byteorder"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b"
+
+[[package]]
+name = "cast"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9434b9a5aa1450faa3f9cb14ea0e8c53bb5d2b3c1bfd1ab4fc03e9f33fbfb0"
+dependencies = [
+ "rustc_version",
+]
+
+[[package]]
+name = "cortex-m"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9075300b07c6a56263b9b582c214d0ff037b00d45ec9fde1cc711490c56f1bb9"
+dependencies = [
+ "aligned",
+ "bare-metal",
+ "bitfield",
+ "cortex-m 0.7.1",
+ "volatile-register",
+]
+
+[[package]]
+name = "cortex-m"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0b756a8bffc56025de45218a48ff9b801180440c0ee49a722b32d49dcebc771"
+dependencies = [
+ "bare-metal",
+ "bitfield",
+ "embedded-hal",
+ "volatile-register",
+]
+
+[[package]]
+name = "cortex-m-rt"
+version = "0.6.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "980c9d0233a909f355ed297ef122f257942de5e0a2cb1c39f60684b65bcb90fb"
+dependencies = [
+ "cortex-m-rt-macros",
+ "r0",
+]
+
+[[package]]
+name = "cortex-m-rt-macros"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4717562afbba06e760d34451919f5c3bf3ac15c7bb897e8b04862a7428378647"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cortex-m-rtic"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b30efcb6b7920d9016182c485687f0012487032a14c415d2fce6e9862ef8260e"
+dependencies = [
+ "cortex-m 0.6.7",
+ "cortex-m-rt",
+ "cortex-m-rtic-macros",
+ "heapless",
+ "rtic-core",
+ "version_check",
+]
+
+[[package]]
+name = "cortex-m-rtic-macros"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a1a6a4c9550373038c0e21a78d44d529bd697c25bbf6b8004bddc6e63b119c7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rtic-syntax",
+ "syn",
+]
+
+[[package]]
+name = "cortex-m-semihosting"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bffa6c1454368a6aa4811ae60964c38e6996d397ff8095a8b9211b1c1f749bc"
+dependencies = [
+ "cortex-m 0.7.1",
+]
+
+[[package]]
+name = "embedded-hal"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa998ce59ec9765d15216393af37a58961ddcefb14c753b4816ba2191d865fcb"
+dependencies = [
+ "nb 0.1.3",
+ "void",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ed1e761351b56f54eb9dcd0cfaca9fd0daecf93918e1cfc01c8a3d26ee7adcd"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "hash32"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
+
+[[package]]
+name = "hbj11-flasher-bluepill"
+version = "0.1.0"
+dependencies = [
+ "cortex-m 0.6.7",
+ "cortex-m-rt",
+ "cortex-m-rtic",
+ "cortex-m-semihosting",
+ "embedded-hal",
+ "nb 0.1.3",
+ "num-derive",
+ "num-traits",
+ "panic-halt",
+ "panic-semihosting",
+ "stm32f1xx-hal",
+ "usb-device",
+ "usbd-webusb",
+]
+
+[[package]]
+name = "heapless"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74911a68a1658cfcfb61bc0ccfbd536e3b6e906f8c2f7883ee50157e3e2184f1"
+dependencies = [
+ "as-slice",
+ "generic-array 0.13.2",
+ "hash32",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb1fa934250de4de8aef298d81c729a7d33d8c239daa3a7575e6b92bfc7313b"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
+[[package]]
+name = "nb"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
+dependencies = [
+ "nb 1.0.0",
+]
+
+[[package]]
+name = "nb"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "546c37ac5d9e56f55e73b677106873d9d9f5190605e41a856503623648488cae"
+
+[[package]]
+name = "num-derive"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "panic-halt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de96540e0ebde571dc55c73d60ef407c653844e6f9a1e2fdbd40c07b9252d812"
+
+[[package]]
+name = "panic-semihosting"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d55dedd501dfd02514646e0af4d7016ce36bc12ae177ef52056989966a1eec"
+dependencies = [
+ "cortex-m 0.7.1",
+ "cortex-m-semihosting",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r0"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2a38df5b15c8d5c7e8654189744d8e396bddc18ad48041a500ce52d6948941f"
+
+[[package]]
+name = "rtic-core"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bd58a6949de8ff797a346a28d9f13f7b8f54fa61bb5e3cb0985a4efb497a5ef"
+
+[[package]]
+name = "rtic-syntax"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8152fcaa845720d61e6cc570548b89144c2c307f18a480bbd97e55e9f6eeff04"
+dependencies = [
+ "indexmap",
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "semver"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
+dependencies = [
+ "semver-parser",
+]
+
+[[package]]
+name = "semver-parser"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
+name = "stm32-usbd"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70d13eca735cae37df697f599777b000cc0ee924df8452f2b4bfaa6798ab0338"
+dependencies = [
+ "cortex-m 0.6.7",
+ "usb-device",
+ "vcell",
+]
+
+[[package]]
+name = "stm32f1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "849b1e8d9bcfd792c9d9178cf86165d299a661c26e35d9322ae9382d3f3fe460"
+dependencies = [
+ "bare-metal",
+ "cortex-m 0.6.7",
+ "cortex-m-rt",
+ "vcell",
+]
+
+[[package]]
+name = "stm32f1xx-hal"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9b9e5d7c2901ee39fc9527412327a1fe08f1d84e9d7f4b3497448e655e5098"
+dependencies = [
+ "as-slice",
+ "cast",
+ "cortex-m 0.6.7",
+ "cortex-m-rt",
+ "embedded-hal",
+ "nb 0.1.3",
+ "stm32-usbd",
+ "stm32f1",
+ "void",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "typenum"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
+
+[[package]]
+name = "usb-device"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "849eed9b4dc61a1f17ba1d7a5078ceb095b9410caa38a506eb281ed5eff12fbd"
+
+[[package]]
+name = "usbd-webusb"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed33ecaa7a26365f13059e753bfa23f0a4a557565499f46d255c51e737464bd8"
+dependencies = [
+ "usb-device",
+]
+
+[[package]]
+name = "vcell"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002"
+
+[[package]]
+name = "version_check"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
+[[package]]
+name = "volatile-register"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d67cb4616d99b940db1d6bd28844ff97108b498a6ca850e5b6191a532063286"
+dependencies = [
+ "vcell",
+]
diff --git a/dc/hbj11/flasher/bluepill/Cargo.toml b/dc/hbj11/flasher/bluepill/Cargo.toml
new file mode 100644
index 0000000..efa7892
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "hbj11-flasher-bluepill"
+version = "0.1.0"
+authors = ["Serge Bazanski <q3k@hackerspace.pl>"]
+edition = "2018"
+
+[profile.dev]
+opt-level = 3
+
+[profile.release]
+opt-level = 'z'
+lto = true
+
+[dependencies]
+cortex-m = "^0.6.3"
+cortex-m-rtic = "^0.5.5"
+cortex-m-rt = "^0.6.12"
+cortex-m-semihosting = "^0.3.7"
+embedded-hal = "^0.2.4"
+panic-halt = "^0.2.0"
+usb-device = "^0.2.7"
+usbd-webusb = "^1.0.2"
+panic-semihosting = "^0.5.0"
+nb = "^0.1.3"
+num-derive = "0.3"
+
+[dependencies.num-traits]
+default-features = false
+features = []
+version = "0.2"
+
+[dependencies.stm32f1xx-hal]
+features = ["stm32f103", "rt", "medium", "stm32-usbd"]
+version = "^0.6.1"
diff --git a/dc/hbj11/flasher/bluepill/README.md b/dc/hbj11/flasher/bluepill/README.md
new file mode 100644
index 0000000..67af491
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/README.md
@@ -0,0 +1,79 @@
+STM32 Bluepill-based I2C Flasher
+================================
+
+
+[TOC]
+
+![](../../doc/bluepill.jpg)
+
+This is a Rust project that runs on an STM32F103C8T6 on a common [bluepill](https://stm32-base.org/boards/STM32F103C8T6-Blue-Pill.html) board.
+
+It acts as a USB device, exposing an interface to perform arbitrary I2C operations.
+
+Hardware
+--------
+
+You will need a buepill with an STM32103C8T6 and a way to flash ELFs on it. An ST-Link or BlackMagicProbe (potentially running on another Bluepill) are good choices.
+
+For flashing the HBJ11 (or any other Dell M610 storage card) you will also need a PCIe x8 socket. The connections to make are as follows:
+
+| Bluepill/STM32 | Function | PCIe Slot |
+| -------------- | -------- | --------- |
+| G/GND          | Ground   | B7        |
+| 3.3/VCC        | 3.3V     | B10       |
+| B6             | SCL      | B11       |
+| B7             | SDA      | B12       |
+
+Note: the PCIe slot pin numbering follows the same convention as Dell parts (they have A1-A49/B1-B49 markers) and as [the Wikipedia article on PCIe](https://en.wikipedia.org/wiki/PCI_Express#Pinout).
+
+Note: you will need to add pull up resistors for SCL and SDA. 4k7 is a good value to start with. Use a scope to make sure the open drain/pullup behaviour looks sensible.
+
+Note: we run the I2C bus and EEPROM at 3.3V, even though it runs at 5V while in a server. This is fine for HBJ11 flashing, but might lead to issues when attempting to read/program Dell parts, like CERC6/i or the JM475.
+
+Firmware
+--------
+
+To build the firmware, you will need Rust with the thumbv7m-none-eabi target. We unfortunately don't have Bazel integration yet, as rules\_rust don't integrate fully with Bazel's toolchain/configurability system. This should be revisited at some point.
+
+To get Rust with the right target, rustup is recommended (Nix users: `nix-shell -p rustup`):
+
+    $ rustup update
+    $ rustup default stable
+    $ rustup target add thumbv7m-none-eabi
+
+Then, to build:
+
+    $ cargo build --release
+    $ file target/thumbv7m-none-eabi/release/hbj11-flasher-bluepill
+    target/thumbv7m-none-eabi/release/hbj11-flasher-bluepill: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped
+
+Debug builds are also available, but they require a semihosting debugger attached - otherwise, they will immediately get stuck trying to log debug messages to the host.
+
+Flashing Firmware
+-----------------
+
+If using a BlackMagicProbe:
+
+    $ arm-none-eabi-gdb -x flash.gdb target/thumbv7m-none-eabi/release/hbj11-flasher-bluepill
+    [...]
+    Loading section .vector_table, size 0x130 lma 0x8000000
+    Loading section .text, size 0x3820 lma 0x8000130
+    Loading section .rodata, size 0xd88 lma 0x8003950
+    Start address 0x08000130, load size 18136
+    Transfer rate: 15 KB/sec, 906 bytes/write.
+
+You can then C-c C-d and let the device run, or keep running it under the debugger. It should enumerate via USB:
+
+    $ lsusb -v | grep -A 4 0x16c0
+      idVendor           0x16c0 Van Ooijen Technische Informatica
+      idProduct          0x27d8 libusb-bound devices
+      bcdDevice            0.10
+      iManufacturer           1 Warsaw Hackerspace
+      iProduct                2 Web I2C Programmer
+
+We currently use an, uh, _community_ VID/PID. This will change in the future as we apply for a pair from pid.codes or elsewhere.
+
+Usage
+-----
+
+The flasher is controller via [WebI2C](../web/) (through WebUSB).
diff --git a/dc/hbj11/flasher/bluepill/flash.gdb b/dc/hbj11/flasher/bluepill/flash.gdb
new file mode 100644
index 0000000..e447bfc
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/flash.gdb
@@ -0,0 +1,7 @@
+# Flash script for BlackMagicProbe
+target extended-remote /dev/ttyACM0
+monitor swdp_scan
+attach 1
+load
+# Attach to the running process. C-c and C-d to detach.
+run
diff --git a/dc/hbj11/flasher/bluepill/memory.x b/dc/hbj11/flasher/bluepill/memory.x
new file mode 100644
index 0000000..d152927
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/memory.x
@@ -0,0 +1,7 @@
+MEMORY
+{
+  /* Flash memory begins at 0x80000000 and has a size of 64kB*/
+  FLASH : ORIGIN = 0x08000000, LENGTH = 64K
+  /* RAM begins at 0x20000000 and has a size of 20kB*/
+  RAM : ORIGIN = 0x20000000, LENGTH = 20K
+}
diff --git a/dc/hbj11/flasher/bluepill/src/i2c.rs b/dc/hbj11/flasher/bluepill/src/i2c.rs
new file mode 100644
index 0000000..c66a133
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/src/i2c.rs
@@ -0,0 +1,372 @@
+/// USB Device Class for I2C transactions.
+//
+// It's not very good, and the API is weird. Someone with more USB device design experience could
+// easily come up with something better.
+//
+// Control OUT transactions are used to perform I2C transfers to/from an internal buffer.
+// Bulk IN/OUT transactions are used to transfer contents of the buffer to the host. It has not
+// been optimized for speed or pipelining.
+//
+// To perform an I2C read:
+//   1) Control OUT: ReadI2C(Address: 0xAA, Length: N)
+//      (0xAA is the device address, N is the amount of bytes to read. Cannot be larger than
+//      BUFFER_SIZE).
+//      This performs an I2C read of N bytes into the inne buffer of the device, starting at
+//      address 0.
+//   2) Control IN: GetStatus()
+//      The host ensures that the transaction was either ACK or NACK by getting one byte of status
+//      from the device.
+//   3) Control OUT: ReadBuffer(Address: X, Length: N)
+//      (X is the address within the buffer, N is the amount of bytes to transfer to the host. N
+//      cannot be larger than PACKET_SIZE).
+//   4) Bulk IN: Read PACKET_SIZE bytes.
+// Steps 3/4 can be skipped for scanning (the device won't mind the inner buffer not being read).
+//
+// To perform an I2C write:
+//   1) Control OUT: SetWritePointer(Addrss: X)
+//   2) Bulk OUT: Write at most PACKET_SIZE bytes.
+// Repeat steps 1/2 to fill buffer with an I2c transaction.
+//   3) Control OUT: WriteI2C(Address: 0x00, Length: N)
+//      (0xAA is the device address, N is the amount of bytes to write. Cannot be larger than
+//      BUFFER_SIZE).
+//   4) Control IN: GetStatus()
+//      The host ensures that the transaction was either ACK or NACK by getting one byte of status
+//      from the device.
+
+use embedded_hal::digital::v2::OutputPin;
+use usb_device::class_prelude::*;
+use nb::Error as NbError;
+use stm32f1xx_hal::{
+    gpio::{gpiob::*, Alternate, OpenDrain},
+    i2c::{BlockingI2c, Error as I2CError},
+    pac::I2C1,
+    prelude::*,
+};
+
+use num_derive::FromPrimitive;
+use num_traits::FromPrimitive;
+
+use crate::{hprint, hprintln};
+
+// Size of buffer within class, in bytes. Dictates maximum I2C transaction size.
+const BUFFER_SIZE: usize = 1024;
+// Size of bulk packets.
+const PACKET_SIZE: usize = 64;
+
+// All IN/OUT references bellow conform to typical USB naming, where IN: from device to host; OUT:
+// from host to device.
+
+/// Request number passed within Control IN requests to the I2C interface (ie. 'gets' from device).
+#[derive(FromPrimitive)]
+#[repr(u8)]
+enum ControlInRequest {
+    /// Write the current status as a single byte in response.
+    GetStatus = 1,
+}
+
+/// Request number passed within Control OUT requests to the I2C interface (ie. 'sets' from the
+/// host).
+#[derive(FromPrimitive)]
+#[repr(u8)]
+enum ControlOutRequest {
+    /// Set LED on or off (value == 0 -> off; on otherwise).
+    SetLED = 1,
+
+    /// Perform I2C bus read of a given length from a given I2C address.
+    /// I2C Address: lower 8 bits of value.
+    /// Read Length: upper 8 bits of value.
+    ReadI2C = 2,
+
+    /// Schedule a BULK IN transaction on the USB bus with the contents of the inner buffer.
+    /// Buffer start address: lower 8 bits of value
+    /// Read Length: upper 8 bits of value.
+    ReadBuffer = 3,
+
+    /// Perform I2C bus write of a given length to a given I2C address.
+    /// I2C Address: lower 8 bits of value.
+    /// Read Length: upper 8 bits of value.
+    WriteI2C = 4,
+
+    /// Set inner buffer write pointer. Any subsequent BULK OUT will write to the buffer at that
+    /// address (but will not auto advance the pointer).
+    SetWritePointer = 5,
+}
+
+/// Status of the I2C class. Combines information about requested transactions and I2C bus
+/// responses.
+#[derive(Copy, Clone)]
+#[repr(u8)]
+enum Status {
+    /// Last request okay.
+    OK = 0,
+    /// Last request contained an invalid argument.
+    InvalidArgument = 1,
+    /// Last request okay, resulted in a successful I2C transaction.
+    Ack = 2,
+    /// Last request okay, resulted in a NACKd I2C transaction.
+    Nack = 3,
+    /// Last request okay, resulted in a fully failed I2C transaction.
+    BusError = 4,
+}
+
+pub struct I2CClass<'a, B: UsbBus, LED> {
+    interface: InterfaceNumber,
+    /// Bulk IN endpoint for buffer transfers to host.
+    ep_in: EndpointIn<'a, B>,
+    /// Bulk OUT endpoint for buffer transfers from host.
+    ep_out: EndpointOut<'a, B>,
+
+    /// LED used for debugging.
+    led: LED,
+
+    /// The underlying I2C device.
+    i2c_dev: BlockingI2c<I2C1, (PB6<Alternate<OpenDrain>>, PB7<Alternate<OpenDrain>>)>,
+
+    /// Marker that is true when the host requested a BULK OUT via ReadBuffer.
+    expect_bulk_out: bool,
+
+    /// The underlying buffer and its write pointer.
+    buffer: [u8; BUFFER_SIZE],
+    write_pointer: usize,
+
+    /// The device's main status byte, used by host to check whether operations were succesful.
+    status: Status,
+}
+
+impl<B: UsbBus, LED: OutputPin> I2CClass<'_, B, LED> {
+    pub fn new(
+        alloc: &UsbBusAllocator<B>,
+        led: LED,
+        i2c_dev: BlockingI2c<I2C1, (PB6<Alternate<OpenDrain>>, PB7<Alternate<OpenDrain>>)>,
+    ) -> I2CClass<'_, B, LED> {
+        I2CClass {
+            interface: alloc.interface(),
+            ep_in: alloc.bulk(PACKET_SIZE as u16),
+            ep_out: alloc.bulk(PACKET_SIZE as u16),
+            led, i2c_dev,
+
+            expect_bulk_out: false,
+
+            buffer: [0; BUFFER_SIZE],
+            write_pointer: 0usize,
+            status: Status::OK,
+        }
+    }
+}
+
+impl<'a, B: UsbBus, LED: OutputPin> UsbClass<B> for I2CClass<'a, B, LED> {
+    fn reset(&mut self) {
+        self.expect_bulk_out = false;
+        self.status = Status::OK;
+    }
+
+    fn control_in(&mut self, xfer: ControlIn<B>) {
+        let req = xfer.request();
+
+        if req.request_type != control::RequestType::Vendor
+            || req.recipient != control::Recipient::Interface
+            || req.index != u8::from(self.interface) as u16 {
+            return
+        }
+
+        match FromPrimitive::from_u8(req.request) {
+            /// Serve GetStatus: return this.status.
+            Some(ControlInRequest::GetStatus) => {
+                let status = self.status.clone() as u8;
+                xfer.accept(|buf| {
+                  buf[0] = status;
+                  Ok(1usize)
+                }).ok();
+            },
+            _ => {
+                hprintln!("Unhandled control in on iface: {:?}", req).unwrap();
+            },
+        }
+    }
+
+    fn control_out(&mut self, xfer: ControlOut<B>) {
+        let req = xfer.request();
+
+        if req.request_type != control::RequestType::Vendor
+            || req.recipient != control::Recipient::Interface
+            || req.index != u8::from(self.interface) as u16 {
+            return
+        }
+
+        match FromPrimitive::from_u8(req.request) {
+            // Serve SetLED.
+            Some(ControlOutRequest::SetLED) => {
+                let on: bool = req.value > 0;
+                match on {
+                    true => self.led.set_low(),
+                    false => self.led.set_high(),
+                }.ok();
+                xfer.accept().ok();
+            },
+
+            // Serve ReadI2C: read len bytes from I2C addr into internal buffer.
+            Some(ControlOutRequest::ReadI2C) => {
+                let addr: u8 = (req.value & 0xff) as u8;
+                let len: u8 = (req.value >> 8) as u8;
+                if len as usize > BUFFER_SIZE || len < 1u8 {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+                if addr > 127u8 {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+                match self.i2c_dev.read(addr, &mut self.buffer[0usize..(len as usize)]) {
+                    Ok(_) => {
+                        self.status = Status::Ack;
+                    },
+                    Err(NbError::Other(I2CError::Acknowledge)) => {
+                        self.status = Status::Nack;
+                    },
+                    Err(e) => {
+                        hprintln!("When reading I2C (addr {}, {} bytes): {:?}", addr, len, e).ok();
+                        self.status = Status::BusError;
+                    },
+                }
+                xfer.accept().ok();
+            },
+
+            // Serve ReadBuffer: send BULK IN with slice of buffer.
+            Some(ControlOutRequest::ReadBuffer) => {
+                let addr: u8 = (req.value & 0xff) as u8;
+                let len: u8 = (req.value >> 8) as u8;
+
+                if len as usize > PACKET_SIZE || len < 1u8 {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+
+                let start = addr as usize;
+                let end = (addr + len) as usize;
+                if end as usize > BUFFER_SIZE {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+
+                hprintln!("READ BUFFER, addr: {}, len: {}", addr, len).ok();
+
+                self.status = Status::OK;
+                xfer.accept().ok();
+                match self.ep_in.write(&self.buffer[start..end]) {
+                    Ok(count) => {
+                    },
+                    Err(UsbError::WouldBlock) => {},
+                    Err(err) => {
+                        hprintln!("bulk write failed: {:?}", err).ok();
+                    },
+                }
+            },
+
+            // Serve WriteI2C: write len bytes to I2C bus at addr from internal buffer.
+            Some(ControlOutRequest::WriteI2C) => {
+                let addr: u8 = (req.value & 0xff) as u8;
+                let len: u8 = (req.value >> 8) as u8;
+                if len as usize > BUFFER_SIZE || len < 1u8 {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+                if addr > 127u8 {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+
+                hprintln!("WRITE I2C, addr: {}, len: {}", addr, len).ok();
+                match self.i2c_dev.write(addr, &self.buffer[0usize..(len as usize)]) {
+                    Ok(_) => {
+                        self.status = Status::Ack;
+                    },
+                    Err(NbError::Other(I2CError::Acknowledge)) => {
+                        self.status = Status::Nack;
+                    },
+                    Err(e) => {
+                        hprintln!("When writing I2C (addr {}, {} bytes): {:?}", addr, len, e).ok();
+                        self.status = Status::BusError;
+                    },
+                }
+                xfer.accept().ok();
+            },
+
+            // Serve SetWritePointer: set start address at which bytes from a BULK OUT will be
+            // written to. The write pointer does _not_ increment on every write, so will need to
+            // be manually controler after every BULK transfer.
+            Some(ControlOutRequest::SetWritePointer) => {
+                let pointer = req.value;
+                if (pointer as usize) >= BUFFER_SIZE {
+                    self.status = Status::InvalidArgument;
+                    xfer.accept().ok();
+                    return
+                }
+                hprintln!("SET WRITE PTR, pointer: {}", pointer).ok();
+                self.write_pointer = pointer as usize;
+                self.status = Status::OK;
+                xfer.accept().ok();
+            },
+            _ => {
+                hprintln!("Unhandled control out on iface: {:?}", req).ok();
+            },
+        }
+    }
+
+    fn get_configuration_descriptors(
+        &self,
+        writer: &mut DescriptorWriter,
+    ) -> usb_device::Result<()> {
+        writer.interface(
+            self.interface,
+            0xff,
+            21, 37,
+        )?;
+        writer.endpoint(&self.ep_in)?;
+        writer.endpoint(&self.ep_out)?;
+
+        Ok(())
+    }
+
+    fn poll(&mut self) {
+        let mut temp_buf = [0; PACKET_SIZE];
+        // Serve BULK OUT writes - copy bytes into internal buffer.
+        match self.ep_out.read(&mut temp_buf) {
+            Ok(count) => {
+                if self.expect_bulk_out {
+                    self.expect_bulk_out = false;
+                } else {
+                    panic!("unexpectedly read data from bulk out endpoint");
+                }
+                hprintln!("SET BUFFER: ptr {}, {} bytes", self.write_pointer, count).ok();
+                for (i, c) in temp_buf.iter().enumerate() {
+                    let ptr = self.write_pointer + i;
+                    // Silently drop bytes that do not fit in buffer.
+                    if ptr >= BUFFER_SIZE {
+                        continue;
+                    }
+                    self.buffer[ptr] = c.clone();
+                }
+            },
+            Err(UsbError::WouldBlock) => {},
+            Err(err) => panic!("bulk read {:?}", err),
+        }
+    }
+
+    fn endpoint_out(&mut self, addr: EndpointAddress) {
+        if addr == self.ep_out.address() {
+            self.expect_bulk_out = true;
+        }
+    }
+
+    fn endpoint_in_complete(&mut self, addr: EndpointAddress) {
+        if addr == self.ep_in.address() {
+            // TODO(q3k): should we be doing something here?
+        }
+    }
+}
diff --git a/dc/hbj11/flasher/bluepill/src/main.rs b/dc/hbj11/flasher/bluepill/src/main.rs
new file mode 100644
index 0000000..45279c2
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/src/main.rs
@@ -0,0 +1,137 @@
+#![no_main]
+#![no_std]
+
+extern crate panic_semihosting;
+
+use rtic::app;
+
+use cortex_m::asm::delay;
+use stm32f1xx_hal::{
+    gpio::{gpioc::*, Output, PushPull},
+    i2c::{BlockingI2c, Mode},
+    pac::{Peripherals},
+    prelude::*,
+    usb::{Peripheral, UsbBus, UsbBusType},
+};
+use embedded_hal::digital::v2::OutputPin;
+
+use usb_device::bus;
+use usb_device::prelude::*;
+
+use usbd_webusb::WebUsb;
+
+mod i2c;
+mod print;
+
+// The main RTIC application object. See RTIC documentation for more information about how to read
+// this.
+
+#[app(device = stm32f1xx_hal::stm32, peripherals = true)]
+const APP: () = {
+    struct Resources {
+        usb_dev: UsbDevice<'static, UsbBusType>,
+        webusb: WebUsb<UsbBusType>,
+        // The I2C USB device class that performs the main logic of accessing the I2C bus over USB
+        // for users of the device.
+        i2c: i2c::I2CClass<'static, UsbBusType, PC13<Output<PushPull>>>,
+    }
+
+    /// Idle loop to prevent WFI which in turn prevents debugging.
+    // TODO: make this only happen on debug builds?
+    #[idle]
+    fn idle(_: idle::Context) -> ! {
+        loop {}
+    }
+
+    #[init]
+    fn init(cx: init::Context) -> init::LateResources {
+        static mut USB_BUS: Option<bus::UsbBusAllocator<UsbBusType>> = None;
+
+        let mut flash = cx.device.FLASH.constrain();
+        let mut rcc = cx.device.RCC.constrain();
+
+        let clocks = rcc
+            .cfgr
+            .use_hse(8.mhz())
+            .sysclk(48.mhz())
+            .pclk1(24.mhz())
+            .freeze(&mut flash.acr);
+
+        assert!(clocks.usbclk_valid());
+
+        let mut gpioa = cx.device.GPIOA.split(&mut rcc.apb2);
+        let mut gpiob = cx.device.GPIOB.split(&mut rcc.apb2);
+        let mut gpioc = cx.device.GPIOC.split(&mut rcc.apb2);
+
+        // Active-low LED on bluepill board.
+        let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);
+        led.set_high().ok();
+
+        let mut afio = cx.device.AFIO.constrain(&mut rcc.apb2);
+
+        // BluePill board has a pull-up resistor on the D+ line.
+        // Pull the D+ pin down to send a RESET condition to the USB bus.
+        // This forced reset is needed only for development, without it host
+        // will not reset your device when you upload new firmware.
+        let mut usb_dp = gpioa.pa12.into_push_pull_output(&mut gpioa.crh);
+        usb_dp.set_low().unwrap();
+        delay(clocks.sysclk().0 / 100);
+
+        let usb_dm = gpioa.pa11;
+        let usb_dp = usb_dp.into_floating_input(&mut gpioa.crh);
+
+        let usb = Peripheral {
+            usb: cx.device.USB,
+            pin_dm: usb_dm,
+            pin_dp: usb_dp,
+        };
+
+        *USB_BUS = Some(UsbBus::new(usb));
+
+        let i2c_pins = (
+            gpiob.pb6.into_alternate_open_drain(&mut gpiob.crl),
+            gpiob.pb7.into_alternate_open_drain(&mut gpiob.crl),
+        );
+
+        // Blocking I2C peripheral for use by the I2C app.
+        let i2c_dev = BlockingI2c::i2c1(
+            cx.device.I2C1,
+            i2c_pins,
+            &mut afio.mapr,
+            Mode::standard(100.khz()),
+            clocks,
+            &mut rcc.apb1,
+            1000, 10, 1000, 1000,
+        );
+
+        // I2C app.
+        let i2c = i2c::I2CClass::new(
+            USB_BUS.as_ref().unwrap(),
+            led, i2c_dev,
+         );
+
+        let usb_dev = UsbDeviceBuilder::new(USB_BUS.as_ref().unwrap(), UsbVidPid(0x16c0, 0x27d8))
+            .manufacturer("Warsaw Hackerspace")
+            .product("Web I2C Programmer")
+            // TODO(q3k): generate serial at build time?
+            .serial_number("2137")
+            .build();
+
+        init::LateResources {
+            usb_dev, i2c,
+            webusb: WebUsb::new(
+                USB_BUS.as_ref().unwrap(),
+                usbd_webusb::url_scheme::HTTPS,
+                "hackdoc.hackerspace.pl/dc/hbj11/flasher",
+            ),
+        }
+    }
+
+    #[task(binds = USB_LP_CAN_RX0, resources = [usb_dev, webusb, i2c])]
+    fn usb_lp(cx: usb_lp::Context) {
+        cx.resources
+            .usb_dev
+            .poll(&mut [cx.resources.webusb, cx.resources.i2c]);
+    }
+};
+
diff --git a/dc/hbj11/flasher/bluepill/src/print.rs b/dc/hbj11/flasher/bluepill/src/print.rs
new file mode 100644
index 0000000..3693b00
--- /dev/null
+++ b/dc/hbj11/flasher/bluepill/src/print.rs
@@ -0,0 +1,40 @@
+// Wrappers around hprint(ln) that get disabled during release builds. This prevents us from
+// getting stuck in an hprint when a debugger is detached.
+
+#[cfg(debug_assertions)]
+#[macro_export]
+macro_rules! hprint {
+    ($s:expr) => {
+        cortex_m_semihosting::export::hstdout_str($s)
+    };
+    ($($tt:tt)*) => {
+        cortex_m_semihosting::export::hstdout_fmt(format_args!($($tt)*))
+    };
+}
+#[cfg(debug_assertions)]
+#[macro_export]
+macro_rules! hprintln {
+    () => {
+        cortex_m_semihosting::export::hstdout_str("\n")
+    };
+    ($s:expr) => {
+        cortex_m_semihosting::export::hstdout_str(concat!($s, "\n"))
+    };
+    ($s:expr, $($tt:tt)*) => {
+        cortex_m_semihosting::export::hstdout_fmt(format_args!(concat!($s, "\n"), $($tt)*))
+    };
+}
+
+#[cfg(not(debug_assertions))]
+#[macro_export]
+macro_rules! hprint {
+    () => { Result::<(), ()>::Ok(()) };
+    ($s:expr, $($tt:tt)*) => { Result::<(), ()>::Ok(()) };
+}
+#[cfg(not(debug_assertions))]
+#[macro_export]
+macro_rules! hprintln {
+    () => { Result::<(), ()>::Ok(()) };
+    ($s:expr) => { Result::<(), ()>::Ok(()) };
+    ($s:expr, $($tt:tt)*) => { Result::<(), ()>::Ok(()) };
+}
diff --git a/dc/hbj11/flasher/web/README.md b/dc/hbj11/flasher/web/README.md
new file mode 100644
index 0000000..e9f34be
--- /dev/null
+++ b/dc/hbj11/flasher/web/README.md
@@ -0,0 +1,18 @@
+WebI2C
+======
+
+A WebUSB interface for flashing I2C EEPROMs, notably the HJB11 FRU EEPROM.
+
+Usage
+-----
+
+[Start WebI2C](index.html). This should work on any browser with WebUSB (eg. Chrome, Chromium, Edge).
+
+Development
+-----------
+
+Plain javascript, bring a static file server, or use hackdoc:
+
+    bazel run //devtools/hackdoc  -- -hspki_disable -docroot ~/hscloud
+
+And visit http://127.0.0.1:8080/dc/hbj11/flasher/web/index.html in your web browser.
diff --git a/dc/hbj11/flasher/web/fru.js b/dc/hbj11/flasher/web/fru.js
new file mode 100644
index 0000000..0924418
--- /dev/null
+++ b/dc/hbj11/flasher/web/fru.js
@@ -0,0 +1,289 @@
+/*
+ * Platform Management FRU Information Storage Definition v1.0
+ * Document Revision 1.3, March 24, 2015
+ *
+ * From: https://www.intel.com/content/www/us/en/servers/ipmi/ipmi-platform-mgt-fru-infostorage-def-v1-0-rev-1-3-spec-update.html
+ */
+export class FRUParser {
+    constructor(data) {
+        this.data = data
+    }
+
+    parseCommon(data) {
+        // 8. Common Header Format
+        let version = data[0];
+        if ((version >> 4) !== 0) throw new Error("Invalid Common Header version");
+        if ((version & 0b1111) !== 1) throw new Error("Invalid Common Header version");
+
+        let res = {};
+        res.version = version;
+        res.internalUseStart = data[1] * 8;
+        res.chassisInfoStart = data[2] * 8;
+        res.boardInfoStart = data[3] * 8;
+        res.productInfoStart = data[4] * 8;
+        res.multiRecordInfoStart = data[5] * 8;
+
+        let sum = data.reduce((a, b) => a + b, 0) & 0xff;
+        if (sum !== 0) throw new Error("Common area checksum error");
+
+        return res;
+    }
+
+    parseBoardInfo(data) {
+        // 11. Board Info Area Format
+        let res = {};
+
+        let version = data[0];
+        if ((version >> 4) !== 0) throw new Error("Invalid Board Info version");
+        if ((version & 0b1111) !== 1) throw new Error("Invalid Board Info version");
+        res.version = version;
+
+        let areaLength = data[1] * 8;
+        if (areaLength > data.length) throw new Error("Invalid Board Info length");
+        data = data.slice(0, areaLength);
+
+        let sum = data.reduce((a, b) => a + b, 0) & 0xff;
+        if (sum !== 0) throw new Error("Board Info Area checksum error");
+
+        let r = new Reader(data);
+        r.skip(2);
+
+        res.language = r.readLanguageCode();
+        res.manufacturingDate = r.readDateTime();
+        res.manufacturerName = r.readTypeLength(res.language);
+        res.productName = r.readTypeLength(res.language);
+        res.serialNumber = r.readTypeLength(res.language);
+        res.partNumber = r.readTypeLength(res.language);
+        res.fruFileID = r.readTypeLength(res.language);
+        res.custom = r.readTypeLength(res.language);
+        // Not sure if this is up to standard - the standard seems to say that
+        // C1 must always appear, but the Dell storage cards I've looked at
+        // skip it. There's an earlier C1, but that's part of the FRU File ID.
+        if (res.length > 0) {
+            if (r.readByte() !== 0xc1) throw new Error("Custom area must end with C1");
+        }
+        return res;
+    }
+
+    parseInternalUseDell(data) {
+        let version = data[0];
+        if ((version >> 4) !== 0) throw new Error("Invalid Internal Use version");
+        if ((version & 0b1111) !== 1) throw new Error("Invalid Internal Use version");
+
+        if ((new TextDecoder().decode(data.slice(1,5))) !== "DELL") {
+            throw new Error("Invalid 'DELL' magic in internal area");
+        }
+
+        let sum = data.reduce((a, b) => a + b, 0) & 0xff;
+        if (sum !== 0) throw new Error("Dell Internal Area checksum error");
+
+        return {};
+    }
+
+    parse() {
+        this.common = this.parseCommon(this.data.slice(0, 8))
+
+        if (this.common.boardInfoStart !== 0) {
+            let data = this.data.slice(this.common.boardInfoStart, this.data.length);
+            this.boardInfo = this.parseBoardInfo(data);
+        } else {
+            this.boardInfo = {};
+        }
+        this.internalUse = {};
+        if (this.common.internalUseStart !== 0) {
+            let data = this.data.slice(this.common.internalUseStart, this.data.length);
+            this.internalUse.dell = this.parseInternalUseDell(data);
+        }
+    }
+
+    stringify() {
+        let res = [];
+        res.push(`Version: ${this.common.version}`)
+        res.push(`Board Info:`)
+        let bi = this.boardInfo;
+        res.push(`           Language: ${bi.language}`)
+        if (bi.manufacturingDate !== undefined)
+            res.push(`  Manufacturing Date: ${bi.manufacturingDate}`);
+        res.push(`  Manufacturer Name: ${bi.manufacturerName}`)
+        res.push(`       Product Name: ${bi.productName}`)
+        res.push(`      Serial Number: ${bi.serialNumber}`)
+        res.push(`         PartNumber: ${bi.partNumber}`)
+        res.push(`        FRU File ID: ${bi.fruFileID}`)
+        
+        if (this.internalUse.dell !== undefined) {
+            res.push("Internal Use: DELL-specific")
+        }
+        return res.join("\n");
+    }
+}
+
+class Reader {
+    constructor(data) {
+        this.data = data;
+    }
+    skip(n) {
+        this.data = this.data.slice(n);
+    }
+    readByte() {
+        let num = this.data[0];
+        this.data = this.data.slice(1);
+        return num;
+    }
+    readLanguageCode() {
+        let num = this.readByte();
+        let encoding = num >> 6;
+        let language = [
+            "en", "aa", "ab", "af", "am", "ar", "as", "ay", "az", "ba", "be",
+            "bg", "bh", "bi", "bn", "bo", "br", "ca", "co", "cs", "cy", "da",
+            "de", "dz", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fj",
+            "fo", "fr", "fy", "ga", "gd", "gl", "gn", "gu", "ha", "hi", "hr",
+            "hu", "hy", "ia", "ie", "ik", "in", "is", "it", "iw", "ja", "ji",
+            "jw", "ka", "kk", "kl", "km", "kn", "ko", "ks", "ku", "ky", "la",
+            "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mo", "mr",
+            "ms", "mt", "my", "na", "ne", "nl", "no", "oc", "om", "or", "pa",
+            "pl", "ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sd",
+            "sg", "sh", "di", "sk", "dl", "sm", "sn", "so", "sq", "sr", "ss",
+            "st", "su", "sv", "sw", "ta", "te", "tg", "th", "ti", "tk", "tl",
+            "tn", "to", "tr", "ts", "tt", "tw", "uk", "ur", "uz", "vi", "vo",
+            "wo", "xh", "yo", "zh", "zu",
+        ][num & 0b111111];
+        return language;
+    }
+    readTypeLength(language) {
+        let tag = this.readByte();
+        let type = tag >> 6;
+        let len = tag & 0b111111;
+        switch (type) {
+          case 0:
+              return this.readTLBinary(len);
+          case 1:
+              return this.readTLBCDPlus(len);
+          case 2:
+              return this.readTL6BASCII(len);
+          case 3:
+              return this.readTLString(len, language);
+        }
+    }
+    readTLBinary(len) {
+        let data = this.data.slice(0, len);
+        this.data = this.data.slice(len);
+        return data;
+    }
+    readTLBCDPlus(len) {
+        let data = this.data.slice(0, len);
+        this.data = this.data.slice(len);
+        const lookup = "012345689 -.???";
+        let res = [];
+        for (const c of data) {
+            let upper = lookup[c >> 4];
+            let lower = lookup[c & 0b1111];
+            if ((upper === "?") || (lower === "?")) {
+                throw new Error("Invalid BCD Plus data");
+            }
+            res.push(upper);
+            res.push(lower);
+        }
+        return res.join("");
+    }
+    readTL6BASCII(len) {
+        let data = this.data.slice(0, len);
+        this.data = this.data.slice(len);
+        const lookup = 
+            " !\"#$%&'()*+,-./" +
+            "0123456789:;<=>?" + 
+            "@ABCDEFGHIJKLMNO" +
+            "PQRSTUVWXYZ[\\]^_";
+
+        let res = [];
+        let availbits = 0;
+        let bits = 0;
+        while ((data.length > 0) || (availbits >= 6)) {
+            if (availbits < 6) {
+                bits |= (data[0] << availbits);
+                availbits += 8;
+                data = data.slice(1);
+            }
+            let n = bits & 0b111111;
+            availbits -= 6;
+            bits >>= 6;
+            res.push(lookup[n]);
+        }
+        return res.join("");
+    }
+    readTLString(len, language) {
+        let data = this.data.slice(0, len);
+        this.data = this.data.slice(len);
+        // 13. Type/Length Byte Format
+        // Yikes, Intel.
+        if (language !== "en") {
+            throw new Error("Unicode unimplemented");
+        }
+        // This should be 'ASCII + Latin 1', but this is a good enough approximation.
+        return new TextDecoder().decode(data);
+    }
+    readDateTime() {
+        let minutes = this.readByte() | (this.readByte() << 8) | (this.readByte() << 16);
+        if (minutes !== 0) throw new Error("Datetime parsing not implemented");
+        return undefined;
+    }
+}
+
+export class HBJ11FRUAssembler {
+    constructor(serial) {
+        this.serial = serial;
+    }
+
+    assemble() {
+        // Strings can be longer in FRU spec, but let's keep it conservative.
+        if (this.serial.length > 8) {
+            throw new Error("Serial too long");
+        }
+        // Same layout as DELL FRUs, board specific after common, internal use after board specific.
+        let common = [0x01, 0x0a, 0x00, 0x01, 0x00, 0x00, 0x00, 0xf4];
+
+        // Similar layout to DELL FRUs, 72 bytes.
+        let board = [
+            0x01, // Version 1
+            0x09, // Length (9*8 == 72 bytes)
+            0x00, 0x00, 0x00, 0x00, // Manufacturing time (unspecified)
+            // Manufacturer: HELL (we can't use anything longer, as the product
+            // name below needs to be in this exact byte offset in the ROM for
+            // the iDRAC to display the name correctly).
+            0x83, 0x68, 0xc9, 0xb2,
+
+            // Product name: bgpwtf SATA Repeater
+            0xDE, 98, 103, 112, 119, 116, 102, 32, 83, 65, 84, 65, 32, 82, 101, 112, 101, 97, 116, 101, 114,
+            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, // ... pad above to 30 chars.
+        ];
+        // Serial number tag/length.
+        board.push(0xC0 | (this.serial.length));
+        // Serial number.
+        for (const c of this.serial) {
+            board.push(c.charCodeAt());
+        }
+        board = board.concat([
+            0xC7, 72, 66, 74, 49, 49, 65, 48, // Part number: HBJ11A0
+            0xC1, 0x02, 0xC1, 0x00, // FRU File ID 2, one-byte custom area/end? Weird shit.
+        ]);
+        if (board.length > 71) {
+            throw new Error("Board Area too long!");
+        }
+        // Pad with zeroes.
+        board = board.concat(Array(71 - board.length).fill(0));
+        // Calculate checksum.
+        let sum = (0xff ^ (board.reduce((a, b) => a + b, 0) & 0xff));
+        board.push((sum + 1) & 0xff);
+
+        // Dell internal use.
+        let dell = [
+          0x01, 0x44, 0x45, 0x4c, 0x4c, 0xf7, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x13, 0x58, 0x01,
+          0x0f, 0x68, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x0d, 0x00, 0xe0, 0x01, 0x00, 0x00, 0x01,
+          0x00, 0x01, 0x01, 0x00,
+        ];
+
+        let eeprom = common.concat(board).concat(dell);
+        // Pad to 256 bytes.
+        eeprom = eeprom.concat(Array(256 - eeprom.length).fill(0));
+        return new Uint8Array(eeprom);
+    }
+}
diff --git a/dc/hbj11/flasher/web/i2c.js b/dc/hbj11/flasher/web/i2c.js
new file mode 100644
index 0000000..6aa4ed7
--- /dev/null
+++ b/dc/hbj11/flasher/web/i2c.js
@@ -0,0 +1,159 @@
+/**
+ * Low-level interface to programmer's I2C USB class.
+ *
+ * See //dc/hbj11/flasher/bluepill/src/i2c.rs for more information.
+ */
+
+const ControlInRequest = Object.freeze({
+  'GetStatus': 1,
+});
+
+const ControlOutRequest = Object.freeze({
+  'SetLED': 1,
+  'ReadI2C': 2,
+  'ReadBuffer': 3,
+  'WriteI2C': 4,
+  'SetWritePointer': 5,
+});
+
+export const Status = Object.freeze({
+  'Idle': 0,
+  'InvalidArgument': 1,
+  'Ack': 2,
+  'Nack': 3,
+  'BusError': 4,
+});
+
+export const StatusFromU8 = function(u8) {
+  for (const label of Object.keys(Status)) {
+    let val = Status[label];
+    if (val === u8) {
+      return label;
+    }
+  }
+};
+
+/**
+ * Low-level interface to programmer's I2C USB class. Thinly wraps available
+ * USB transfers.
+ */
+export class USBI2CClassInterface {
+  /**
+   * @param {USBDevice} usb - The WebUSB device that backs this USB class.
+   */
+  constructor(device) {
+    this.usb = device;
+    this.BUFFER_SIZE = 1024;
+    this.PACKET_SIZE = 64;
+  }
+
+  /**
+   * Open this programmer via WebUSB and finds all required endpoints.
+   */
+  async open() {
+    await this.usb.open();
+    await this.usb.selectConfiguration(1);
+    await this.usb.claimInterface(0);
+
+    let eps = this.usb.configuration.interfaces[0].alternate.endpoints;
+    this.bulk_out = null;
+    this.bulk_in = null;
+    for (const ep of eps) {
+        if (ep.direction == "out" && ep.type == "bulk") {
+            this.bulk_out = ep;
+        }
+        if (ep.direction == "in" && ep.type == "bulk") {
+            this.bulk_in = ep;
+        }
+    }
+    if (this.bulk_out === null) {
+      throw new Error("Could not find bulk out endpoint");
+    }
+    if (this.bulk_in === null) {
+      throw new Error("Could not find bulk in endpoint");
+    }
+  }
+
+  /**
+   * Performs a USB Control OUT request to the I2C class.
+   * @param {number} request - Request number for transfer (0-255).
+   * @param {number} value - Value for transfer (0-65535).
+   * @returns {Promise<USBOutTransferResult>} The underlying WebUSB transfer result.
+   */
+  async controlOut(request, value) {
+    return await this.usb.controlTransferOut({
+      requestType: "vendor",
+      recipient: "interface",
+      request: request,
+      value: value,
+      index: 0,
+    });
+  }
+
+  /**
+   * Read status from programmer.
+   * @returns {Status} The status of the programmer.
+   */
+  async getStatus() {
+    let res = await this.usb.controlTransferIn({
+      requestType: "vendor",
+      recipient: "interface",
+      request: ControlInRequest.GetStatus,
+      value: 0,
+      index: 0
+    }, 1);
+    if (res.data.byteLength < 1) {
+      throw new Error('returned data too short')
+    }
+    return res.data.getInt8(0);
+  }
+
+  /**
+   * Sends SetLED control OUT request.
+   */
+  async setLED(on) {
+    return await this.controlOut(ControlOutRequest.SetLED, on ? 1 : 0);
+  }
+
+  /**
+   * Sends ReadI2C control OUT request.
+   */
+  async readI2C(addr, length) {
+    return await this.controlOut(ControlOutRequest.ReadI2C, (length << 8) | addr);
+  }
+
+  /**
+   * Sends ReadBuffer control OUT request.
+   */
+  async readBuffer(addr, length) {
+    return await this.controlOut(ControlOutRequest.ReadBuffer, (length << 8) | addr);
+  }
+
+  /**
+   * Sends WriteI2C control OUT request.
+   */
+  async writeI2C(addr, length) {
+    return await this.controlOut(ControlOutRequest.WriteI2C, (length << 8) | addr);
+  }
+
+  /**
+   * Sends SetWritePointer control OUT request.
+   */
+  async setWritePointer(addr) {
+    return await this.controlOut(ControlOutRequest.SetWritePointer, addr);
+  }
+
+  /**
+   * Requets bulk IN data.
+   */
+  async bulkIn(length) {
+      return await this.usb.transferIn(this.bulk_in.endpointNumber, length);
+  }
+
+  /**
+   * Sends bulk OUT data.
+   */
+  async bulkOut(data) {
+      return await this.usb.transferOut(this.bulk_out.endpointNumber, data);
+  }
+}
diff --git a/dc/hbj11/flasher/web/index.html b/dc/hbj11/flasher/web/index.html
new file mode 100644
index 0000000..24e1a64
--- /dev/null
+++ b/dc/hbj11/flasher/web/index.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Web I2C Flasher</title>
+    <style>
+      html {
+        background-color: #f0f0f0;
+      }
+      body {
+        padding: 0;
+        margin: 0;
+      }
+      header {
+        padding: 1rem;
+        background-color: #fff;
+        box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
+      }
+      #document {
+        margin: 40px auto;
+        max-width: 750px;
+      }
+      button {
+        display: inline-block;
+        border: 0;
+        background-color: #f8f8f8;
+        color: #333;
+        border: 1px solid #dedede;
+        padding: 0.2rem 0.8rem 0.2rem 0.8rem;
+        font-family: Verdana, Sans-Serif;
+        font-size: 0.65rem;
+        font-weight: 700;
+        text-transform: uppercase;
+        outline: 0;
+      }
+      button + button {
+        margin-left: 0.5rem;
+      }
+      button:hover {
+        background-color: #fefefe;
+      }
+      button:active {
+        background-color: #f0f0f0;
+      }
+      button:disabled {
+        color: #888;
+      }
+      button:disabled:hover {
+        background-color: #f8f8f8;
+        border: 1px solid #dedede;
+      }
+      button.btn-red {
+        padding: 0.4rem 1rem 0.4rem 1rem;
+        background-color: #ff4949;
+        color: #fff;
+        border: 1px solid #8a1c05;
+      }
+      button.btn-red:hover {
+        background-color: #ff5959;
+        border: 1px solid #8a1c05;
+      }
+      button.btn-red:active {
+        background-color: #ff3939;
+        border: 1px solid #8a1c05;
+      }
+      h1,h2,h3 {
+        font-family: Helvetica, Sans-Serif;
+        margin: 0;
+        color: #111;
+      }
+      h1 {
+        font-size: 1.5rem;
+        color: #333;
+      }
+      h2 {
+        margin-bottom: 0.3rem;
+        font-size: 1.2rem;
+      }
+      h3 {
+        font-size: 0.8rem;
+        margin: 0.3rem;
+      }
+      #programmers {
+      }
+      .programmer {
+        width: 100%;
+        margin-top: 1rem;
+        background-color: #fff;
+        box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
+      }
+      .programmerName {
+        font-family: Verdana, sans-serif;
+        font-size: 0.9rem;
+        padding: 1rem;
+        border-bottom: 1px solid #ddd;
+        background-color: #fff;
+      }
+      .programmerOptions {
+        float: right;
+      }
+      .devices {
+        padding: 2rem;
+      }
+      .device {
+        clear: both;
+        background-color: #fafafa;
+        padding: 1rem;
+        border: 1px solid #eee;
+      }
+      .device + .device {
+        margin-top: 1rem;
+      }
+      .deviceName {
+        font-family: Verdana, sans-serif;
+      }
+      .deviceOptions {
+        float: right;
+      }
+      .deviceDump {
+        color: #fff;
+        background-color: #1c1c1c;
+        display: block;
+        clear: both;
+        padding: 1rem;
+        font-family: monospace;
+        margin-top: 1rem;
+        border-radius: 0.3rem;
+      }
+    </style>
+  </head>
+<body>
+  <header>
+    <h1>Web I2C Flash</h1>
+  </header>
+  <div id="document">
+    <button id="connect" class="btn-red">Add...</button>
+    <div id="programmers">
+    </div>
+  </div>
+  <script src="main.js" type="module"></script>
+</body>
+</html>
diff --git a/dc/hbj11/flasher/web/main.js b/dc/hbj11/flasher/web/main.js
new file mode 100644
index 0000000..9691f9c
--- /dev/null
+++ b/dc/hbj11/flasher/web/main.js
@@ -0,0 +1,472 @@
+/*
+ * WebI2C, a web interface for flashing I2C EEPROMS, notably FRU EEPROMs for
+ * the HBJ11.
+ */
+
+import { FRUParser, HBJ11FRUAssembler } from './fru.js';
+import { Status, StatusFromU8, USBI2CClassInterface } from './i2c.js';
+
+/**
+ * I2CDevice is an I2C device (eg. EEPROM) on the I2C bus, attached via a
+ * Programmer.
+ */
+class I2CDevice {
+  constructor(programmer, addr) {
+    this.programmer = programmer;
+    this.addr = addr;
+    this.dump = "";
+  }
+
+  /**
+   * Treat this device as an I2C EEPROM (eg. 24C02) and read its content.
+   * @param {number} addr - The address in the EEPROM to start reading at.
+   * @param {number} length - Count of bytes to read starting at address.
+   * @returns {Promise<Uint8Array>} Contents of the EEPROM.
+   */
+  async readFlash(addr, length) {
+    // Always send a non-zero seek, otherwise 24C02 sometimes NACKs?
+    await this.programmer.writeI2C(this.addr, new Uint8Array([1]));
+    await this.programmer.readI2C(this.addr, 1);
+    
+    // Chunk up reads into 128 bytes.
+    let i = 0;
+    const max_chunk_size = 128;
+    let flash = new Uint8Array(length);
+    while (i < length) {
+        await this.programmer.writeI2C(this.addr, new Uint8Array([i]));
+        let chunk_size = length - i;
+        if (chunk_size > max_chunk_size) {
+          chunk_size = max_chunk_size;
+        }
+        let res = await this.programmer.readI2C(this.addr, chunk_size);
+        flash.set(new Uint8Array(res.buffer), i);
+        i += chunk_size;
+    }
+    return flash;
+  }
+
+  /**
+   * Threat this devices as an I2C EEPROM on a HBJ11 and flash it with a given
+   * serial nyumber.
+   * @param {string} serial - The serial number of the HJB11 to brand it with.
+   * @param {HTMLButtonelement} button - Button used to trigger this action,
+   *                                     will be disabled while the flashing is
+   *                                     performed.
+   */
+  async writeHBJ11(serial, button) {
+      // Always send a non-zero seek, otherwise 24C02 sometimes NACKs?
+      await this.programmer.writeI2C(this.addr, new Uint8Array([1]));
+      await this.programmer.readI2C(this.addr, 1);
+
+      let text = button.innerText;
+      button.disabled = true;
+      button.innerText = "Flashing...";
+
+      let data = new HBJ11FRUAssembler(serial).assemble();
+
+      // Chunk up writes into 16 bytes.
+      let chunks = [];
+      for (let i = 0; i < data.length; i+= 16) {
+          chunks.push([i].concat(Array.from(data.slice(i, i+16))));
+      }
+
+      for (const chunk of chunks) {
+          await this.programmer.writeI2C(this.addr, new Uint8Array(chunk));
+      }
+
+      button.disabled = false;
+      button.innerText = text;
+  }
+
+  render(div) {
+    div.innerHTML = "";
+    let deviceName = document.createElement("div");
+    deviceName.className = "deviceName";
+    deviceName.appendChild(document.createTextNode(`Device 0x${this.addr.toString(16)}`));
+
+    div.appendChild(deviceName);
+
+    let deviceOptions = document.createElement("div");
+    deviceOptions.className = "deviceOptions";
+
+    let readButton = document.createElement("button");
+    readButton.appendChild(document.createTextNode("Read flash"));
+    readButton.onclick = async () => {
+        let res = await this.readFlash(this.addr, 256);
+
+        this.dump = "";
+        const hex = "0123456789ABCDEF";
+        for (let i = 0; i < res.length; i += 16) {
+          let block = res.slice(i, Math.min(i+16, res.length));
+          let addr = ("0000" + i.toString(16)).slice(-4);
+          let codes = Array.from(block.values()).map((code) => {
+            return " " + hex[(0xF0 & code) >> 4] + hex[0x0f & code];
+          }).join("");
+          codes += "   ".repeat(16 - block.length);
+          let chars = Array.from(block.values()).map((code) => {
+            if (code < 0x20 || code > 0x7e) {
+              return ".";
+            }
+            return String.fromCharCode(code);
+          }).join("");
+          codes += " ".repeat(16 - block.length);
+          this.dump += (addr + " " + codes + " " + chars + "\n");
+        }
+
+        let p = new FRUParser(res);
+        try {
+            p.parse();
+            this.dump += "\nFRU EEPROM:\n";
+            this.dump += p.stringify();
+        } catch(err) {
+            this.dump += "\nNot an FRU EEPROM: " + err;
+        }
+
+        console.log(this.dump);
+
+        this.render(div);
+    };
+    deviceOptions.appendChild(readButton);
+
+    let makeButton = document.createElement("button");
+    makeButton.appendChild(document.createTextNode("Make HBJ11"));
+    makeButton.onclick = async () => {
+        await this.writeHBJ11(window.prompt("Enter HBJ11 Serial", "A0000"), makeButton);
+    };
+    deviceOptions.appendChild(makeButton);
+
+    deviceName.appendChild(deviceOptions);
+
+    if (this.dump.length > 0) {
+      let deviceDump = document.createElement("pre");
+
+      deviceDump.className = "deviceDump";
+      deviceDump.innerText = this.dump;
+      div.appendChild(deviceDump);
+    }
+  }
+}
+
+/**
+ * A list of I2CDevices, eg. EEPROMs. Used for DOM rendering.
+ */
+class I2CDeviceList {
+  constructor() {
+    this.list = [];
+  }
+
+  set(devices) {
+    this.list = devices;
+  }
+
+  render(div) {
+    if (this.list.length === 0) {
+      div.innerHTML = "<i>No devices...</i>";
+      return;
+    }
+
+    for (const device of this.list) {
+      let deviceDiv = document.createElement("div");
+      deviceDiv.className = "device";
+      device.render(deviceDiv);
+      div.appendChild(deviceDiv);
+    }
+  }
+}
+
+/**
+ * A WebI2C compatible programmer accessed over USB.
+ */
+class Programmer {
+  /**
+   * @param {USBDevice} usb - The WebUSB device that backs this programmer.
+   */
+  constructor(usb) {
+    this.usb = usb;
+    this.i2c = new USBI2CClassInterface(usb);
+    this.devices = new I2CDeviceList();
+  }
+  /**
+   * Get programmer manufacturer name.
+   * @returns {string} The name.
+   */
+  get manufacturerName() {
+    return this.usb.manufacturerName;
+  }
+
+  /**
+   * Get programmer product name.
+   * @returns {string} The name.
+   */
+  get productName() {
+    return this.usb.productName;
+  }
+
+  /**
+   * Get programmer serial number.
+   * @returns {string} The name.
+   */
+  get serialNumber() {
+    return this.usb.serialNumber;
+  }
+
+  /**
+   * Compares two Programmers and checks if they're using the same WebUSB
+   * device underneath. This is used for housekeeping of the ProgrammerList.
+   */
+  equal(other) {
+    let one = this.usb;
+    let two = other.usb;
+    return (one.vendorId == two.vendorId)
+        && (one.productId == two.productId)
+        && (one.serialNumber == two.serialNumber);
+  }
+
+  /**
+   * Performs an I2C read on the bus of the programmer and reads the resulting
+   * data from the buffer. The readout is performed in chunks over multiple
+   * Bulk transfer.
+   * @param {number} addr - Address of the I2C device to read from.
+   * @param {number} length - Number of bytes to read from I2C (not larger than
+   *                          BUFFER_SIZE).
+   * @returns {object} Object with status and bufer keys. TODO(q3k): declare type.
+   */
+  async readI2C(addr, length) {
+    await this.i2c.readI2C(addr, length);
+    let status = await this.i2c.getStatus();
+    if (status !== Status.Ack) {
+      return {status: status, buffer: null};
+    }
+    let buffer = new Uint8Array(length);
+    let i = 0;
+    while (i < length) {
+      let chunkSize = length - i;
+      if (chunkSize > this.i2c.PACKET_SIZE) {
+        chunkSize = this.i2c.PACKET_SIZE;
+      }
+      let chunk = await this.readBuffer(i, chunkSize);
+      buffer.set(new Uint8Array(chunk.buffer), i);
+      i += chunkSize;
+    }
+    return {status: status, buffer: buffer};
+  }
+
+  /**
+   * Transfers data to internal buffer of programmers and performs an I2C write
+   * with the given data.
+   * @param {number} addr - Address of the I2C to write data to.
+   * @param {ArrayBuffer} data - Data to write to device.
+   */
+  async writeI2C(addr, data) {
+    let i = 0;
+    while (i < data.length) {
+      let end = i + this.i2c.PACKET_SIZE;
+      if (end > data.length) {
+        end = data.length;
+      }
+      let chunk = data.slice(i, end);
+      await this.writeBuffer(i, chunk);
+      i = end;
+    }
+    await this.i2c.writeI2C(addr, data.length);
+  }
+
+  /**
+   * Performs a scan of the I2C bus for all connected devices and upgrades the
+   * internal I2CDeviceList with found I2CDevices.
+   * @param {HTMLButtonElement} button - Button that will be disabled when the
+   *                                     Scan is performed.
+   */
+  async scan(button) {
+    let text = button.innerText;
+    button.innerText = "Scanning...";
+    button.disabled = true;
+
+    let present = [];
+    for (let i = 0; i < 127; i++) {
+      let res = await this.readI2C(i, 1);
+      switch (res.status) {
+        case Status.Ack:
+          present.push(new I2CDevice(this, i));
+          break;
+        case Status.Nack:
+          break;
+        default:
+          throw new Error(`When scanning ${i}: ${StatusFromU8(res.status)}`);
+      }
+    }
+    this.devices.set(present);
+
+    button.disabled = false;
+    button.innerText = text;
+  }
+
+  /**
+   * Blinks the programmer's LED.
+   * @param {HTMLButtonElement} button - Button that will be disabled when the
+   *                                     LED blinks.
+   */
+  async blink(button) {
+    let on = true;
+    button.disabled = true;
+    let text = button.innerText;
+    button.innerText = "Blinking...";
+    for (let i = 0; i < 20; i++) {
+      await this.i2c.setLED(on);
+      await new Promise(r => setTimeout(r, 100));
+      on = !on;
+    }
+    button.disabled = false;
+    button.innerText = text;
+  }
+
+  /**
+   * Requests buffer readout from device via ReadBuffer control transfer and
+   * then performs a single read via the Bulk IN endpoint.
+   * @param {number} addr - Address within the buffer to start read at.
+   * @param {number} length - Number of bytes to read (not larger than
+   *                          PACKET_SIZE).
+   * @returns {ArrayBuffer} Data read from buffer.
+   */
+  async readBuffer(addr, length) {
+    await this.i2c.readBuffer(addr, length);
+    let status = await this.i2c.getStatus();
+    if (status !== Status.Idle) {
+      throw new Error(`When requesting buffer: ${StatusFromU8(res.status)}`);
+    }
+    let res = await this.i2c.bulkIn(length);
+    return res.data;
+  }
+
+  /**
+   * Writes bytes to internal buffer.
+   * @param {number} addr - Address within the buffer to start write at.
+   * @param {ArrayBuffer} data - Data to write to buffer (must not be longer
+   *                             than PACKET_SIZE).
+   */
+  async writeBuffer(addr, data) {
+    await this.i2c.setWritePointer(addr);
+    let status = await this.i2c.getStatus();
+    if (status !== Status.Idle) {
+      throw new Error(`When setting pointer: ${StatusFromU8(res.status)}`);
+    }
+    await this.i2c.bulkOut(data);
+  }
+
+
+  render(div) {
+    let programmer = document.createElement("div");
+    programmer.className = "programmer";
+    let programmerName = document.createElement("div");
+    programmerName.className = "programmerName";
+    programmerName.appendChild(document.createTextNode(this.manufacturerName));
+    programmerName.appendChild(document.createTextNode(" "));
+    let b = document.createElement("b");
+    b.textContent = this.productName;
+    programmerName.appendChild(b);
+
+    let programmerOptions = document.createElement("div");
+    programmerOptions.className = "programmerOptions";
+
+    let blinkButton = document.createElement("button");
+    blinkButton.appendChild(document.createTextNode("Blink LED"));
+    blinkButton.onclick = async () => {
+        await this.blink(blinkButton);
+    };
+    programmerOptions.appendChild(blinkButton);
+
+    let devices = document.createElement("div");
+    devices.className = "devices";
+
+    let scanButton = document.createElement("button");
+    scanButton.appendChild(document.createTextNode("Scan I2C Bus"));
+    scanButton.onclick = async () => {
+        await this.scan(scanButton);
+        devices.innerText = "";
+        this.devices.render(devices)
+    };
+    programmerOptions.appendChild(scanButton);
+
+    programmerName.appendChild(programmerOptions);
+    programmer.appendChild(programmerName);
+
+    this.devices.render(devices)
+    programmer.append(devices);
+
+    div.appendChild(programmer);
+  }
+}
+
+/**
+ * List of Programmers, used for rendering to DOM.
+ */
+class ProgrammerList {
+  constructor(list) {
+    this.list = [];
+    for (const l of list) {
+      this.list.push(l);
+    }
+    this.status = {};
+  }
+  async addProgrammer(programmer) {
+    let existing = this.list.filter(d => d.equal(programmer));
+    if (existing.length == 0) {
+      this.list.push(programmer);
+      await programmer.i2c.open();
+    }
+  }
+  removeProgrammer(programmer) {
+    this.list = this.list.filter(d => !d.equal(programmer));
+  }
+  render() {
+    let div = document.querySelector("#programmers");
+    div.innerText = "";
+    for (const programmer of this.list) {
+        programmer.render(div);
+    }
+  }
+}
+
+if (navigator.usb === undefined || navigator.usb.requestDevice === undefined) {
+    alert("No WebUSB support! Please use a Chromium-based browser.");
+}
+
+// 'global' ProgrammerList, modified by document/USB events.
+let list = null;
+
+document.addEventListener('DOMContentLoaded', async () => {
+    let programmers = (await navigator.usb.getDevices()).map(d => new Programmer(d));
+    for (const programmer of programmers) {
+      await programmer.i2c.open();
+    }
+    list = new ProgrammerList(programmers);
+    list.render();
+});
+
+navigator.usb.addEventListener('connect', async event => {
+    await list.addProgrammer(new Programmer(event.device));
+    list.render();
+});
+
+navigator.usb.addEventListener('disconnect', event => {
+    list.removeProgrammer(new Programmer(event.device));
+    list.render();
+});
+
+document.getElementById("connect").onclick = async () => {
+    let device;
+    try {
+      device = await navigator.usb.requestDevice({
+          filters: [{
+              vendorId: 0x16c0,
+              productId: 0x27d8,
+          }]
+      });
+    } catch (err) {
+      return;
+    };
+    if (device !== undefined) {
+      await list.addProgrammer(new Programmer(device));
+      list.render();
+    }
+};
diff --git a/default.nix b/default.nix
index cc761d4..4540157 100644
--- a/default.nix
+++ b/default.nix
@@ -7,11 +7,11 @@
 
   readTree = import ./nix/readtree.nix {};
 
-  # Tracking nixos-unstable as of 2020-08-22.
-  nixpkgsCommit = "c59ea8b8a0e7f927e7291c14ea6cd1bd3a16ff38";
+  # Tracking nixos-unstable as of 2021-01-31.
+  nixpkgsCommit = "44ad80ab1036c5cc83ada4bfa451dac9939f2a10";
   nixpkgsSrc = fetchTarball {
-    url = "https://github.com/NixOS/nixpkgs-channels/archive/${nixpkgsCommit}.tar.gz";
-    sha256 = "1ak7jqx94fjhc68xh1lh35kh3w3ndbadprrb762qgvcfb8351x8v";
+    url = "https://github.com/NixOS/nixpkgs/archive/${nixpkgsCommit}.tar.gz";
+    sha256 = "1b61nzvy0d46cspy07szkc0rggacxiqg9v1py27pkqpj7rvawfsk";
   };
   nixpkgs = import nixpkgsSrc {
     config.allowUnfree = true;
diff --git a/devtools/bazel-cache/BUILD.bazel b/devtools/bazel-cache/BUILD.bazel
deleted file mode 100644
index 2781a59..0000000
--- a/devtools/bazel-cache/BUILD.bazel
+++ /dev/null
@@ -1,10 +0,0 @@
-load("@io_bazel_rules_docker//container:container.bzl", "container_push")
-
-container_push(
-    name = "push",
-    image = "@com_github_buchgr_bazel_remote//:bazel-remote-image",
-    format = "Docker",
-    registry = "registry.k0.hswaw.net",
-    repository = "devtools/bazel-cache",
-    tag = "latest",
-)
diff --git a/devtools/bazel-cache/prod.jsonnet b/devtools/bazel-cache/prod.jsonnet
deleted file mode 100644
index 6ed1234..0000000
--- a/devtools/bazel-cache/prod.jsonnet
+++ /dev/null
@@ -1,108 +0,0 @@
-# bazel-cache.k0.hswaw.net, a Bazel Cache based on buchgr/bazel-remote.
-# Once https://github.com/bazelbuild/bazel/pull/4889 gets merged, this will
-# likely be replaced with just an Rados GW instance.
-
-local kube = import "../../kube/kube.libsonnet";
-
-{
-    local app = self,
-    local cfg = app.cfg,
-    cfg:: {
-        namespace: "bazel-cache",
-        domain: "k0.hswaw.net",
-        storageClassName: "waw-hdd-yolo-2",
-    },
-
-    metadata(component):: {
-        namespace: cfg.namespace,
-        labels: {
-            "app.kubernetes.io/name": "bazel-cache",
-            "app.kubernetes.io/managed-by": "kubecfg",
-            "app.kubernetes.io/component": component,
-        },
-    },
-
-    namespace: kube.Namespace(cfg.namespace),
-
-    volumeClaim: kube.PersistentVolumeClaim("bazel-cache-storage") {
-        metadata+: app.metadata("bazel-cache-storage"),
-        spec+: {
-            storageClassName: cfg.storageClassName,
-            accessModes: [ "ReadWriteOnce" ],
-            resources: {
-                requests: {
-                    storage: "40Gi",
-                },
-            },
-        },
-    },
-
-
-    deployment: kube.Deployment("bazel-remote") {
-        metadata+: app.metadata("bazel-cache"),
-        spec+: {
-            replicas: 1,
-            template+: {
-                spec+: {
-                    volumes_: {
-                        data: kube.PersistentVolumeClaimVolume(app.volumeClaim),
-                    },
-                    containers_: {
-                        auth: kube.Container("bazel-remote") {
-                            image: "registry.k0.hswaw.net/devtools/bazel-cache:latest",
-                            volumeMounts_: {
-                                data: { mountPath: "/data" },
-                            },
-                            ports_: {
-                                http: {
-                                    containerPort: 8080,
-                                    protocol: "TCP",
-                                },
-                            },
-                        },
-                    },
-                },
-            },
-        },
-    },
-
-    service: kube.Service("bazel-cache") {
-        metadata+: app.metadata("bazel-cache"),
-        target_pod:: app.deployment.spec.template,
-        spec+: {
-            type: "ClusterIP",
-            ports: [
-                { name: "http", port: 8080, targetPort: 8080, protocol: "TCP" },
-            ],
-        }
-    },
-
-    ingress: kube.Ingress("bazel-cache") {
-        metadata+: app.metadata("bazel-cache") {
-            annotations+: {
-                "kubernetes.io/tls-acme": "true",
-                "certmanager.k8s.io/cluster-issuer": "letsencrypt-prod",
-                "nginx.ingress.kubernetes.io/backend-protocol": "HTTP",
-                "nginx.ingress.kubernetes.io/proxy-body-size": "0",
-            },
-        },
-        spec+: {
-            tls: [
-                {
-                    hosts: ["bazel-cache.%s" % [cfg.domain]],
-                    secretName: "bazel-cache-tls",
-                },
-            ],
-            rules: [
-                {
-                    host: "bazel-cache.%s" % [cfg.domain],
-                    http: {
-                        paths: [
-                            { path: "/", backend: app.service.name_port },
-                        ]
-                    },
-                }
-            ],
-        },
-    },
-}
diff --git a/devtools/ci/remote-cache/BUILD.bazel b/devtools/ci/remote-cache/BUILD.bazel
new file mode 100644
index 0000000..4d46955
--- /dev/null
+++ b/devtools/ci/remote-cache/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "main.go",
+        "service.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/devtools/ci/remote-cache",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_minio_minio_go_v7//:go_default_library",
+        "@com_github_minio_minio_go_v7//pkg/credentials:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "remote-cache",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/devtools/ci/remote-cache/README.md b/devtools/ci/remote-cache/README.md
new file mode 100644
index 0000000..667f39a
--- /dev/null
+++ b/devtools/ci/remote-cache/README.md
@@ -0,0 +1,34 @@
+remote-cache
+============
+
+A small Go service that acts as a [Bazel remote cache HTTP server](https://docs.bazel.build/versions/master/remote-caching.html#http-caching-protocol) and is backed in Ceph.
+
+Status
+------
+
+Work in progress, does not run on prod yet, needs write authentication support first.
+
+Building
+--------
+
+    bazel build //devtools/ci/remote-cache
+
+Running locally
+---------------
+
+For now, you'll have to manually acquire some Ceph RadosGW/S3 keys. When you have them:
+
+    bazel run //devtools/ci/remote-cache -- \
+        -object_access_key YOURACCESSKEY -object_secret_key yourSecretAccessKey -object_bucket your-bucket
+
+Then, tell Bazel to connect when building something:
+
+    bazel build --remote_cache=http://127.0.0.1:8080 //cluster/prodvider
+
+You should see something like this, if you ended up mostly doing GETs:
+
+    INFO: Elapsed time: 40.149s, Critical Path: 30.40s
+    INFO: 705 processes: 705 remote cache hit.
+    INFO: Build completed successfully, 718 total actions
+
+This will be slower than building without cache if you mostly PUT cache elements, and will likely even be slower on GETs unless you have excellent connectivity to k0. The remote cache is only a building block used to make builds faster, and we will need more things (eg. Remote Build Execution and CI) to actually get speedups for developer builds.
diff --git a/devtools/ci/remote-cache/main.go b/devtools/ci/remote-cache/main.go
new file mode 100644
index 0000000..dfb23a6
--- /dev/null
+++ b/devtools/ci/remote-cache/main.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+	"flag"
+	"net"
+	"net/http"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+
+	"github.com/golang/glog"
+	"github.com/minio/minio-go/v7"
+	"github.com/minio/minio-go/v7/pkg/credentials"
+)
+
+var (
+	flagListenPublic    = ":8080"
+	flagObjectEndpoint  = "object.ceph-waw3.hswaw.net"
+	flagObjectAccessKey = ""
+	flagObjectSecretKey = ""
+	flagObjectBucket    = ""
+	flagObjectPrefix    = "cache/"
+)
+
+func main() {
+	flag.StringVar(&flagListenPublic, "listen_public", flagListenPublic, "Address to listen on for Bazel HTTP caching protocol clients")
+	flag.StringVar(&flagObjectEndpoint, "object_endpoint", flagObjectEndpoint, "Object Storage endpoint name")
+	flag.StringVar(&flagObjectAccessKey, "object_access_key", flagObjectEndpoint, "Object Storage AccessKey")
+	flag.StringVar(&flagObjectSecretKey, "object_secret_key", flagObjectEndpoint, "Object Storage SecretKey")
+	flag.StringVar(&flagObjectBucket, "object_bucket", flagObjectBucket, "Object Storage bucket name")
+	flag.StringVar(&flagObjectPrefix, "object_prefix", flagObjectPrefix, "Object Storage prefix for paths")
+	flag.Parse()
+
+	if flagObjectBucket == "" {
+		glog.Exitf("object_bucket must be set")
+	}
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	minioClient, err := minio.New(flagObjectEndpoint, &minio.Options{
+		Creds:  credentials.NewStaticV4(flagObjectAccessKey, flagObjectSecretKey, ""),
+		Secure: true,
+	})
+
+	if err != nil {
+		glog.Exitf("Failed to initialize Object Storage client: %v", err)
+	}
+
+	s := newService(minioClient, flagObjectBucket, flagObjectPrefix)
+
+	httpListen, err := net.Listen("tcp", flagListenPublic)
+	if err != nil {
+		glog.Exitf("net.Listen: %v", err)
+	}
+	httpServer := &http.Server{
+		Addr:    flagListenPublic,
+		Handler: s.publicHandler,
+	}
+
+	errs := make(chan error, 0)
+	go func() {
+		glog.Infof("Public listening on %s", flagListenPublic)
+		errs <- httpServer.Serve(httpListen)
+	}()
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	select {
+	case <-m.Done():
+	case err := <-errs:
+		glog.Exitf("Serve(): %v", err)
+	}
+}
diff --git a/devtools/ci/remote-cache/service.go b/devtools/ci/remote-cache/service.go
new file mode 100644
index 0000000..70c9d18
--- /dev/null
+++ b/devtools/ci/remote-cache/service.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"github.com/golang/glog"
+	"github.com/minio/minio-go/v7"
+)
+
+type service struct {
+	objectClient  *minio.Client
+	objectBucket  string
+	objectPrefix  string
+	publicHandler http.Handler
+}
+
+func newService(objectClient *minio.Client, objectBucket, objectPrefix string) *service {
+	s := &service{
+		objectClient: objectClient,
+		objectBucket: objectBucket,
+		objectPrefix: objectPrefix,
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", s.handlePublic)
+	s.publicHandler = mux
+	return s
+}
+
+func (s *service) handlePublic(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	switch r.Method {
+	case "GET":
+		// Always allow GET access to cache.
+	case "PUT":
+		// Require authentication for cache writes.
+		// TODO(q3k): implement
+	default:
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
+	if len(parts) != 2 {
+		http.NotFound(w, r)
+		return
+	}
+	switch parts[0] {
+	case "ac":
+	case "cas":
+	default:
+		http.NotFound(w, r)
+		return
+	}
+
+	if len(parts[1]) != 64 {
+		http.NotFound(w, r)
+		return
+	}
+
+	cacheKey := fmt.Sprintf("%s%s/%s", s.objectPrefix, parts[0], parts[1])
+	glog.Infof("%s %s %s", r.RemoteAddr, r.Method, cacheKey)
+
+	if r.Method == "GET" {
+		obj, err := s.objectClient.GetObject(ctx, s.objectBucket, cacheKey, minio.GetObjectOptions{})
+		if err != nil {
+			glog.Errorf("GetObject(%s, %s): %v", s.objectBucket, cacheKey, err)
+			http.Error(w, "could not contact object store", http.StatusInternalServerError)
+			return
+		}
+
+		_, err = obj.Stat()
+		// Minio-go doesn't seem to let us do this in any nicer way :/
+		if err != nil && err.Error() == "The specified key does not exist." {
+			http.NotFound(w, r)
+			return
+		} else if err != nil {
+			glog.Errorf("Stat(%s, %s): %v", s.objectBucket, cacheKey, err)
+			http.Error(w, "could not contact object store", http.StatusInternalServerError)
+			return
+		}
+
+		// Stream object to client.
+		io.Copy(w, obj)
+	}
+	if r.Method == "PUT" {
+		// Buffer the file, as we need to check its sha256.
+		// TODO(q3k): check and limit body size.
+		data, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			glog.Errorf("ReadAll: %v", err)
+			return
+		}
+		hashBytes := sha256.Sum256(data)
+		hash := hex.EncodeToString(hashBytes[:])
+		// Bazel cache uploads always seem to use lowercase sha256
+		// representations.
+		if parts[0] == "cas" && hash != parts[1] {
+			glog.Warningf("%s: sent PUT for %s with invalid hash %s", r.RemoteAddr, cacheKey, hash)
+			// Don't tell the user anything - Bazel won't care, anyway, and us
+			// logging this is probably good enough for debugging purposes.
+			return
+		}
+		// If the file already exists in the cache, ignore it. S3 doesn't seem
+		// to give us an upload-if-missing functionality?
+		_, err = s.objectClient.StatObject(ctx, s.objectBucket, cacheKey, minio.StatObjectOptions{})
+		if err == nil {
+			// File already exists, return early.
+			// This might not fire in case we fail to retrieve the object for
+			// some reason other than its nonexistence, but an error will be
+			// served for this at PutObject later on.
+			return
+		}
+
+		buffer := bytes.NewBuffer(data)
+		_, err = s.objectClient.PutObject(ctx, s.objectBucket, cacheKey, buffer, int64(len(data)), minio.PutObjectOptions{
+			UserMetadata: map[string]string{
+				"remote-cache-origin": r.RemoteAddr,
+			},
+		})
+		if err != nil {
+			// Swallow the error. Can't do much for the bazel writer, anyway.
+			// Retrying here isn't easy, as we don't want to become a
+			// qeueue/buffer unless really needed.
+			glog.Errorf("%s: PUT %s failed: %v", r.RemoteAddr, cacheKey, err)
+			return
+		}
+	}
+}
diff --git a/devtools/gerrit/BUILD b/devtools/gerrit/BUILD
index 898b13f..9f089fd 100644
--- a/devtools/gerrit/BUILD
+++ b/devtools/gerrit/BUILD
@@ -2,7 +2,7 @@
 
 container_image(
     name="with_plugins",
-    base="@gerrit-3.3.0//image",
+    base="@gerrit-3.3.2//image",
     files = [
         "//devtools/gerrit/gerrit-oauth-provider:gerrit-oauth-provider",
         "@com_googlesource_gerrit_plugin_owners//owners:owners.jar",
@@ -12,9 +12,32 @@
     # to overwrite plugins.
     directory = "/var/gerrit-plugins",
 )
+
 container_image(
-    name="3.3.0-r7",
-    base=":with_plugins",
+    name = "with_theme",
+    base = ":with_plugins",
+    files = [
+        "theme/etc/GerritSite.css",
+        "theme/static/pepper-icon.png",
+    ],
+    directory = "/var/gerrit-theme",
+)
+
+# Add gerrit 3.3.2 with backported fix. See org_q3k_gerrit_3_3_2_backport in
+# WORKSPACE for more background.
+# TODO(q3k): drop once gerrit > 3.3.2 lands.
+container_image(
+    name = "with_gerrit_override",
+    base="with_theme",
+    files = [
+        "@org_q3k_gerrit_3_3_2_backport//file:gerrit.war",
+    ],
+    directory = "/var/gerrit/bin/",
+)
+
+container_image(
+    name="3.3.2-r4",
+    base=":with_gerrit_override",
     files = [":entrypoint.sh"],
     directory = "/",
     entrypoint = ["/entrypoint.sh"],
@@ -22,9 +45,9 @@
 
 container_push(
     name = "push",
-    image = ":3.3.0-r7",
+    image = ":3.3.2-r4",
     format = "Docker",
     registry = "registry.k0.hswaw.net",
     repository = "q3k/gerrit",
-    tag = "3.3.0-r7",
+    tag = "3.3.2-r4",
 )
diff --git a/devtools/gerrit/entrypoint.sh b/devtools/gerrit/entrypoint.sh
index ffea5f3..f303263 100755
--- a/devtools/gerrit/entrypoint.sh
+++ b/devtools/gerrit/entrypoint.sh
@@ -19,6 +19,10 @@
 
 cp /var/gerrit-plugins/* /var/gerrit/plugins/
 
+mkdir -p /var/gerrit/static
+cp -r /var/gerrit-theme/*png /var/gerrit/static/
+cp -r /var/gerrit-theme/*css /var/gerrit/etc/
+
 echo "Starting config updater..."
 # Keep copying config over in background. We cannot run directly from
 # the configmap filesystem as gerrit really wants a read-write FS.
diff --git a/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/BUILD b/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/BUILD
deleted file mode 100644
index 252c327..0000000
--- a/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/BUILD
+++ /dev/null
@@ -1,9 +0,0 @@
-load("//tools/bzl:classpath.bzl", "classpath_collector")
-
-classpath_collector(
-    name = "main_classpath_collect",
-    testonly = 1,
-    deps = [
-        "//:gerrit-oauth-provider__plugin_test_deps",
-    ],
-)
diff --git a/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/project.sh b/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/project.sh
deleted file mode 100755
index 8e4ed79..0000000
--- a/devtools/gerrit/gerrit-oauth-provider/tools/eclipse/project.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-# Copyright (C) 2017 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-`bazel query @com_googlesource_gerrit_bazlets//tools/eclipse:project --output location | sed s/BUILD:.*//`project.py -n oauth -r .
diff --git a/devtools/gerrit/kube/gerrit.libsonnet b/devtools/gerrit/kube/gerrit.libsonnet
index 00272a1..1a466bd 100644
--- a/devtools/gerrit/kube/gerrit.libsonnet
+++ b/devtools/gerrit/kube/gerrit.libsonnet
@@ -38,7 +38,7 @@
             address: "gerrit@hackerspace.pl",
         },
 
-        tag: "3.3.0-r7",
+        tag: "3.3.2-r4",
         image: "registry.k0.hswaw.net/q3k/gerrit:" + cfg.tag,
         resources: {
             requests: {
diff --git a/devtools/gerrit/theme/etc/GerritSite.css b/devtools/gerrit/theme/etc/GerritSite.css
new file mode 100644
index 0000000..97074f2
--- /dev/null
+++ b/devtools/gerrit/theme/etc/GerritSite.css
@@ -0,0 +1,7 @@
+html {
+    --header-background-color: #347dbe;
+    --header-text-color: #fff;
+    --header-title-content: "Gerrit";
+    --header-icon: url('/static/pepper-icon.png');
+    --header-icon-size: 1em;
+}
diff --git a/devtools/gerrit/theme/static/pepper-icon.png b/devtools/gerrit/theme/static/pepper-icon.png
new file mode 100644
index 0000000..7d12ab8
--- /dev/null
+++ b/devtools/gerrit/theme/static/pepper-icon.png
Binary files differ
diff --git a/devtools/hackdoc/main.go b/devtools/hackdoc/main.go
index e460721..10fb65c 100644
--- a/devtools/hackdoc/main.go
+++ b/devtools/hackdoc/main.go
@@ -205,7 +205,15 @@
 		}
 
 		if file {
-			http.Redirect(r.w, r.r, "/"+fpath+"?ref="+r.ref, 302)
+			ref := r.ref
+			if ref == flagGitwebDefaultBranch {
+				ref = ""
+			}
+			path := "/" + fpath
+			if ref != "" {
+				path += "?ref=" + ref
+			}
+			http.Redirect(r.w, r.r, path, 302)
 			return
 		}
 	}
diff --git a/devtools/hackdoc/markdown.go b/devtools/hackdoc/markdown.go
index 5004642..a6f206d 100644
--- a/devtools/hackdoc/markdown.go
+++ b/devtools/hackdoc/markdown.go
@@ -21,7 +21,7 @@
 
 	// master is the default branch - do not make special links for that, as
 	// that makes them kinda ugly.
-	if ref == "master" {
+	if ref == flagGitwebDefaultBranch {
 		ref = ""
 	}
 
@@ -151,7 +151,14 @@
 	}
 
 	// Just serve the file.
-	mime := mimetype.Detect(data)
-	r.w.Header().Set("Content-Type", mime.String())
+	var mime string
+	if strings.HasSuffix(path, ".js") {
+		// Force .js to always be the correct MIME type.
+		mime = "text/javascript"
+	} else {
+		// Otherwise, use magic to detect type.
+		mime = mimetype.Detect(data).String()
+	}
+	r.w.Header().Set("Content-Type", mime)
 	r.w.Write(data)
 }
diff --git a/devtools/hackdoc/source/source.go b/devtools/hackdoc/source/source.go
index 73d8990..71dc3a5 100644
--- a/devtools/hackdoc/source/source.go
+++ b/devtools/hackdoc/source/source.go
@@ -3,7 +3,7 @@
 import "context"
 
 var (
-	FlagGitwebURLPattern = "https://gerrit.hackerspace.pl/plugins/gitiles/hscloud/+/%s/%s"
+	FlagGitwebURLPattern = "https://cs.hackerspace.pl/hscloud@%s/-/blob/%s"
 )
 
 type Source interface {
diff --git a/devtools/hackdoc/source/source_depotview.go b/devtools/hackdoc/source/source_depotview.go
index 6a256be..a278796 100644
--- a/devtools/hackdoc/source/source_depotview.go
+++ b/devtools/hackdoc/source/source_depotview.go
@@ -113,7 +113,7 @@
 func (s *depotViewSource) WebLinks(fpath string) []WebLink {
 	gitURL := fmt.Sprintf(FlagGitwebURLPattern, s.hash, fpath)
 	links := []WebLink{
-		WebLink{Kind: "gitweb", LinkLabel: s.hash[:16], LinkURL: gitURL},
+		WebLink{Kind: "source", LinkLabel: s.hash[:16], LinkURL: gitURL},
 	}
 
 	if s.change != 0 {
diff --git a/devtools/hackdoc/source/source_local.go b/devtools/hackdoc/source/source_local.go
index feecd8b..e901f45 100644
--- a/devtools/hackdoc/source/source_local.go
+++ b/devtools/hackdoc/source/source_local.go
@@ -72,6 +72,6 @@
 func (s *LocalSource) WebLinks(fpath string) []WebLink {
 	gitURL := fmt.Sprintf(FlagGitwebURLPattern, "master", fpath)
 	return []WebLink{
-		WebLink{Kind: "gitweb", LinkLabel: "master", LinkURL: gitURL},
+		WebLink{Kind: "source", LinkLabel: "master", LinkURL: gitURL},
 	}
 }
diff --git a/devtools/hackdoc/tpl/base.html b/devtools/hackdoc/tpl/base.html
index 5fd861a..8b93b39 100644
--- a/devtools/hackdoc/tpl/base.html
+++ b/devtools/hackdoc/tpl/base.html
@@ -3,9 +3,358 @@
     <head>
         <meta charset="utf-8">
         <title>hackdoc:{{ .Title }}</title>
-        {{ template "head" . }}
+        <style type="text/css">
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed, 
+figure, figcaption, footer, header, hgroup, 
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+	margin: 0;
+	padding: 0;
+	border: 0;
+	font-size: 100%;
+	font: inherit;
+	vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure, 
+footer, header, hgroup, menu, nav, section {
+	display: block;
+}
+body {
+	line-height: 1;
+}
+ol, ul {
+	list-style: none;
+}
+blockquote, q {
+	quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+	content: '';
+	content: none;
+}
+table {
+	border-collapse: collapse;
+	border-spacing: 0;
+}
+
+body {
+    font-size: 14px;
+    line-height: 1.25em;
+    background-color: #f0f0f0;
+}
+
+html {
+    /* Cursed snippet to prevent entire page being shifted left when scrollbar
+       appears. */
+    overflow-x: hidden;
+    margin-right: calc(-1 * (100vw - 100%));
+}
+
+.wrapper {
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    width: 100%;
+    font-family: 'Open Sans', sans-serif;
+}
+
+.column {
+    width: 80em;
+    padding: 1rem 0 1rem 0;
+}
+
+.page {
+    background-color: #fefefe;
+    width: 100%;
+
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+}
+
+.content {
+    order: 1;
+    padding: 1rem 1rem 3rem 3rem;
+    width: 55em;
+}
+
+.sidebar {
+    order: 2;
+    width: 20em;
+    margin-left: 1em;
+    padding-top: 1rem;
+    padding-bottom: 3rem;
+}
+
+.sidebar ul {
+    padding: .5em;
+    border: 1px solid #ddd;
+    margin-top: 1em;
+    margin-right: 2em;
+    background-color: #f8f8f8;
+    font-size: 1em;
+}
+
+
+.header {
+    font-size: 1.2em;
+    font-family: Consolas, monospace;
+    margin-top: 1rem;
+    display: inline-flex;
+    background-color: #fefefe;
+    width: 100%;
+}
+
+.headerInner {
+    padding: 1em 1em 1em 3em;
+}
+
+.header a {
+    text-decoration: none;
+}
+.header a:hover {
+    text-decoration: underline;
+}
+
+.header span.red {
+    color: #b30014;
+}
+
+.header span.part {
+    color: #666;
+    padding-left: 0.2em;
+}
+
+.header span.part a {
+    color: rgb(27, 106, 203);
+}
+.header span.part a:visited {
+    color: rgb(27, 106, 203);
+}
+
+.topbar {
+    min-height: 1em;
+    padding: 1em 1em 1em 3.5em;
+}
+
+.topbar a {
+    text-decoration: none;
+    color: #333;
+    font-size: 1em;
+}
+.topbar a:hover {
+    text-decoration: underline;
+}
+
+.topbar span {
+    margin-right: 2em;
+}
+
+.footer {
+    font-size: 0.8em;
+    color: #ccc;
+    font-weight: 800;
+    padding: 0.5em 1em 1em;
+    text-align: right;
+}
+
+.footer .left {
+    float: left;
+}
+
+.footer .right {
+    float: right;
+}
+
+.footer a {
+    color: #bbb;
+}
+
+.page h1 {
+    font-size: 1.9em;
+    padding: 1em 0 0 0;
+    font-weight: 400;
+    color: #000;
+}
+
+.page p + h1 {
+    padding: 2em 0 0 0;
+}
+
+.page h2 {
+    font-size: 1.5em;
+    padding: 1.6em 0 0 0;
+    color: #000;
+    font-weight: 400;
+}
+
+.page h3 {
+    font-size: 1.4em;
+    padding: 0.4em 0 0 0;
+    color: #333;
+}
+
+.page h4 {
+    font-size: 1.0em;
+    color: #444;
+}
+
+.page strong {
+    font-weight: 600;
+}
+
+.page code {
+    font-family: Consolas, monospace;
+    background-color: #f8f8f8;
+}
+
+.page pre {
+    background-color: #f8f8f8;
+    border: 1px solid #d8d8d8;
+    margin: 2em 1em 2em 1em;
+    padding: 0.5em;
+    overflow: auto;
+    max-width: 60em;
+}
+
+.page h1 + p {
+    margin-top: 2em;
+}
+
+.page h2 + p {
+    margin-top: 1em;
+}
+
+.page h3 + p {
+    margin-top: 1em;
+}
+
+.page h4 + p {
+    margin-top: 1em;
+}
+
+
+.page p + p {
+    margin-top: 1em;
+}
+
+.page p {
+    line-height: 1.6em;
+}
+
+.page :not(li) > ul {
+    padding-top: 0.5em;
+    line-height: 1.5em;
+}
+
+.page ul li {
+    padding-left: 1em;
+}
+
+.page :not(li) > ul > li::before {
+    content: "•";
+    color: #333;;
+    display: inline-block;
+    width: 1em;
+    margin-left: -0.5em;
+}
+
+.page li > ul > li::before {
+    content: "◦";
+    color: #333;;
+    display: inline-block;
+    width: 1em;
+    margin-left: -0.5em;
+}
+
+.page img {
+    max-width: 50em;
+    margin: 1em 0 1em 0;
+    display: block;
+}
+
+.page table {
+    border: 1px solid #ddd;
+    width: 100%;
+    max-width: 100%;
+    margin-bottom: 20px;
+    margin-top: 20px;
+}
+
+.page table th {
+    border-bottom-width: 2px;
+    border: 1px solid #ddd;
+    border: 1px solid #e36372;
+    padding: 5px;
+    text-align: left;
+    vertical-align: bottom;
+    background-color: #e36372;
+    font-weight: 800;
+    color: #fff;
+}
+.page table td {
+    border: 1px solid #e36372;
+    padding: 5px;
+    vertical-align: top;
+    background-color: #ffdbdf;
+}
+
+.page table code {
+    font-family: Consolas, monospace;
+    background-color: #fcedef;
+}
+
+.toc {
+    padding: .5em;
+    border: 1px solid #ddd;
+    background-color: #f8f8f8;
+    margin: 2em;
+    max-width: 30em;
+    font-size: 1em;
+}
+
+.toc a {
+    text-decoration: none;
+}
+
+        </style>
     </head>
     <body>
-        {{ template "body" . }}
+        <div class="wrapper">
+            <div class="column">
+                <div class="header">
+                    <div class="headerInner">
+                        {{ template "header" . }}
+                    </div>
+                </div>
+                <div class="topbar">
+                    {{ template "topbar" . }}
+                </div>
+
+                <div class="page">
+                    <div class="sidebar">
+                        <h3>Page Info</h3>
+                        <ul>
+                            {{ range .WebLinks }}
+                            <li><a href="{{.LinkURL}}">View {{.Kind}} ({{ .LinkLabel }})</a></li>
+                            {{ end }}
+                            <li><a href="{{ .HackdocURL }}/devtools/hackdoc">Generated by hackdoc</a></li>
+                        </ul>
+                    </div>
+                    {{ .Rendered }}
+                </div>
+            </div>
+        </div>
     </body>
 </html>
diff --git a/devtools/hackdoc/tpl/default.html b/devtools/hackdoc/tpl/default.html
index 6e1434e..24439bb 100644
--- a/devtools/hackdoc/tpl/default.html
+++ b/devtools/hackdoc/tpl/default.html
@@ -1,280 +1,14 @@
-{{ define "head" }}
-<style type="text/css">
-html, body, div, span, applet, object, iframe,
-h1, h2, h3, h4, h5, h6, p, blockquote, pre,
-a, abbr, acronym, address, big, cite, code,
-del, dfn, em, img, ins, kbd, q, s, samp,
-small, strike, strong, sub, sup, tt, var,
-b, u, i, center,
-dl, dt, dd, ol, ul, li,
-fieldset, form, label, legend,
-table, caption, tbody, tfoot, thead, tr, th, td,
-article, aside, canvas, details, embed, 
-figure, figcaption, footer, header, hgroup, 
-menu, nav, output, ruby, section, summary,
-time, mark, audio, video {
-	margin: 0;
-	padding: 0;
-	border: 0;
-	font-size: 100%;
-	font: inherit;
-	vertical-align: baseline;
-}
-/* HTML5 display-role reset for older browsers */
-article, aside, details, figcaption, figure, 
-footer, header, hgroup, menu, nav, section {
-	display: block;
-}
-body {
-	line-height: 1;
-}
-ol, ul {
-	list-style: none;
-}
-blockquote, q {
-	quotes: none;
-}
-blockquote:before, blockquote:after,
-q:before, q:after {
-	content: '';
-	content: none;
-}
-table {
-	border-collapse: collapse;
-	border-spacing: 0;
-}
-
-body {
-    font-size: 14px;
-    line-height: 1.25em;
-    background-color: #f0f0f0;
-}
-
-.wrapper {
-    display: flex;
-    flex-direction: row;
-    justify-content: center;
-    width: 100%;
-}
-
-.column {
-    width: 80em;
-    padding: 1rem 0 1rem 0;
-}
-
-.page {
-    background-color: #fefefe;
-    padding: 0.5rem 2rem 3rem 2rem;
-}
-
-.header {
-    font-size: 1.2em;
-    font-family: Consolas, monospace;
-    margin-top: 1rem;
-    padding: 0.5em 0 0.5em 0;
-    display: inline-flex;
-}
-
-.header a {
-    text-decoration: none;
-}
-.header a:hover {
-    text-decoration: underline;
-}
-
-.header span.red {
-    color: #b30014;
-}
-
-.header span.part {
-    color: #666;
-    padding-left: 0.2em;
-}
-
-.header span.part a {
-    color: rgb(27, 106, 203);
-}
-.header span.part a:visited {
-    color: rgb(27, 106, 203);
-}
-
-.footer {
-    font-size: 0.8em;
-    color: #ccc;
-    font-weight: 800;
-    font-family: helvetica, arial, sans-serif;
-    padding: 0.5em 1em 1em;
-    text-align: right;
-}
-
-.footer .left {
-    float: left;
-}
-
-.footer .right {
-    float: right;
-}
-
-.footer a {
-    color: #bbb;
-}
-
-h1,h2,h3,h4 {
-    font-family: helvetica, arial, sans-serif;
-}
-
-.content h1 {
-    font-size: 1.6em;
-    padding: 1em 0 0 0;
-    font-weight: 800;
-}
-
-.content h2 {
-    font-size: 1.3em;
-    padding: 0.8em 0 0 0;
-    color: #333;
-    font-weight: 800;
-}
-
-.content h3 {
-    font-size: 1.2em;
-    padding: 0.4em 0 0 0;
-    color: #444;
-}
-
-.content h4 {
-    font-size: 1.0em;
-    color: #555;
-}
-
-.content strong {
-    font-weight: 600;
-}
-
-.content code {
-    font-family: Consolas, monospace;
-    background-color: #f8f8f8;
-}
-
-.content pre {
-    background-color: #f8f8f8;
-    border: 1px solid #d8d8d8;
-    margin: 1em;
-    padding: 0.5em;
-    overflow: auto;
-}
-
-.content p {
-    margin-top: 0.8em;
-    line-height: 1.5em;
-}
-
-.content :not(li) > ul {
-    padding-top: 0.5em;
-    line-height: 1.5em;
-}
-
-.content ul li {
-    padding-left: 1em;
-}
-
-.content :not(li) > ul > li::before {
-    content: "•";
-    color: #333;;
-    display: inline-block;
-    width: 1em;
-    margin-left: -0.5em;
-}
-
-.content li > ul > li::before {
-    content: "◦";
-    color: #333;;
-    display: inline-block;
-    width: 1em;
-    margin-left: -0.5em;
-}
-
-.content img {
-    max-width: 90%;
-    margin: 1em auto 1em auto;
-    display: block;
-}
-
-.content table {
-    border: 1px solid #ddd;
-    width: 100%;
-    max-width: 100%;
-    margin-bottom: 20px;
-    margin-top: 20px;
-}
-
-.content table th {
-    border-bottom-width: 2px;
-    border: 1px solid #ddd;
-    border: 1px solid #e36372;
-    padding: 5px;
-    text-align: left;
-    vertical-align: bottom;
-    background-color: #e36372;
-    font-weight: 800;
-    font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
-    color: #fff;
-}
-.content table td {
-    border: 1px solid #e36372;
-    padding: 5px;
-    vertical-align: top;
-    background-color: #ffdbdf;
-    font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
-}
-
-.content table code {
-    font-family: Consolas, monospace;
-    background-color: #fcedef;
-}
-
-.toc {
-    padding: .5em;
-    border: 1px solid #ddd;
-    background-color: #f8f8f8;
-    margin: 2em;
-    max-width: 30%;
-    font-size: 1em;
-    font-family: sans-serif;
-}
-
-.toc a {
-    text-decoration: none;
-}
-
-</style>
+{{ define "header" }}
+<span class="red">hackdoc://</span>
+<span>HSCloud Docs</span>
 {{ end }}
-{{ define "body" }}
-<div class="wrapper">
-    <div class="column">
-        <div class="page">
-            <div class="header">
-                <span class="red">hackdoc:</span>
-                {{ range .PathParts }}
-                    {{ if ne .Path "" }}
-                        <span class="part"><a href="{{ .Path }}">{{ .Label }}</a></span>
-                    {{ else }}
-                        <span class="part">{{ .Label }}</span>
-                    {{ end }}
-                {{ end }}
-                <span class="red" style="margin-left: 1em;">shortcuts:</span> <a href="/">root</a>, <a href="/cluster/doc">cluster docs</a>, <a href="/doc/codelabs">codelabs</a>
-            </div>
-            {{ .Rendered }}
-        </div>
-        <div class="footer">
-            <div class="left">
-                View in:
-                {{ range .WebLinks }}
-                <span class="muted">[{{ .Kind }} <a href="{{ .LinkURL }}">{{ .LinkLabel }}</a>]</span>
-                {{ end }}
-            </div>
-            <div class="right">Generated by <a href="{{ .HackdocURL }}/devtools/hackdoc">hackdoc</a>.</div>
-        </div>
-    </div>
-</div>
+
+{{ define "topbar" }}
+<span><a href="/">Home</a></span>
+<span><a href="/cluster/">Cluster</a></span>
+<span><a href="/dc/">DC</a></span>
+<span><a href="/doc/codelabs">Codelabs</a></span>
+{{ end }}
+
+{{ define "sidebar" }}
 {{ end }}
diff --git a/devtools/kube/hackdoc.libsonnet b/devtools/kube/hackdoc.libsonnet
index cb1c319..4fd7ad6 100644
--- a/devtools/kube/hackdoc.libsonnet
+++ b/devtools/kube/hackdoc.libsonnet
@@ -3,7 +3,7 @@
 
 {
     cfg:: {
-        image: "registry.k0.hswaw.net/q3k/hackdoc:1606469587-42b21ecd84b713faf1c65c4ddceb74f559fe94bd",
+        image: "registry.k0.hswaw.net/q3k/hackdoc:315532800-f4d02581f60b18a8635d026079ed67039cdc45e6",
         publicFQDN: error "public FQDN must be set",
     },
 
diff --git a/doc/codelabs/getting-started/checking-out.md b/doc/codelabs/getting-started/checking-out.md
new file mode 100644
index 0000000..1e3a8c6
--- /dev/null
+++ b/doc/codelabs/getting-started/checking-out.md
@@ -0,0 +1,110 @@
+Checking out hscloud
+====================
+
+This codelab will introduce you to the basics of working with hscloud - including what it it, how to use it, and what next steps can you take afterwards. **It's strongly recommended for everyone who wants to interact with hscloud, k0, or any of the 'new style' Warsaw Hackerspace infrastructure**.
+
+By the end, you'll have a hscloud checkout and general understanding of what's where and what tools are available to interact with hscloud.
+
+[TOC]
+
+Background
+----------
+
+Welcome to **hscloud**! This is a repository of code and set of infrastructure born around the Warsaw Hackerspace. It started off as a monorepo for infrastructure-as-code for a set of cloud-like infrastructure at the hackerspace, but has since grown to accumulate a bunch of semi-related code: everything from Nix machine definitions to game servers.
+
+What do I use it for?
+---------------------
+
+If you've been pointed towards hscloud, it's probably due to one of the below reasons:
+
+ - You want to deploy something for the Hackerspace
+ - You want to fix/patch some software already running
+ - You want to run some of your personal projects on our compute cluster
+
+All of these are first-class functionality within **hscloud**, and they should be as straightforward to use as possible. If anything doesn't make immediate sense, or you are confused by something that seems unclear - contact q3k and he will make sure to help you out and update the documentation so that the next person doesn't have this issue.
+
+This documentation, by the way, is also kept within hscloud!
+
+What is hackdoc?
+----------------
+
+If you're viewing this on [https://hackdoc.hackerspace.pl/doc/codelabs/getting-started/checking-out.md](https://hackdoc.hackerspace.pl/doc/codelabs/getting-started/checking-out.md) - you're using [Hackdoc](/devtools/hackdoc)! Hackdoc renders some of the code within hscloud as more prose-oriented documentation, like these codelabs. Somewhere on the right hand side of this page you should be able to click a *View Source* button to see what document this was rendered from.
+
+You could instead also be reading this from [cs.hackerspace.pl, our code browser view](https://cs.hackerspace.pl/hscloud) or [gerrit.hackerspace.pl, our code review tool](https://gerrit.hackerspace.pl). Both of these will show you the same content as the hackdoc view, but less richly rendered and linked. We highly recommend viewing this via Hackdoc as linked above for the best possible experience.
+
+Local requirements & Git clone
+==============================
+
+First, you need to make a local clone of hscloud on your development machine - ie., the computer/laptop you want to work on. We support generic glibc-based Linux distributions (eg. Ubuntu, Debian, Fedora), NixOS and to a certain degree, macOS.
+
+Unless you're running something different from the above, we **highly recommend not working on hscloud from within a container or VM**, as our build system already takes care of hermetic builds and reproducibility - and containers/VMs will only get in the way.
+
+Once you've found a machine and `cd`'d to a directory where you want to clone hscloud, do the following:
+
+    $ git clone https://gerrit.hackerspace.pl/hscloud.git
+    [...]
+    $ cd hscloud
+    $ head -n 4 doc/codelabs/getting-started/checking-out.md
+
+and you should see the source for this document, proving that you've succesfully gotten hold of a copy of hscloud.
+
+Code organization
+-----------------
+
+**hscloud is a branchless monorepo**. If you've never worked with such a setup before, you might find it slightly odd and coutnerintuitive. Here's what we mean by branchless monorepo:
+
+  - There is only a single branch we work on: `master`. Any time you want to introduce a change to hscloud, you always start off `master` at the newest possible HEAD, add some changes on top, and then get these reviewed and merged. We do not do feature branches, release trains, or release versions.
+  - There are no personal branches. Instead, if you want to commit some stuff for personal/experimental use, just drop it into a `//personal/$your-username`, and you will be able to land things there without review.
+  - There is a single repository. All projects and code lives within hscloud. This allows you to make a single change that alters, for examplle, client and server side code of an application simultaneously, without having to coordinate this change across multiple repositories.
+
+What's up with `//these/paths`?
+-------------------------------
+
+These paths, sometimes called *depot paths*, are a convention to refer to paths within hscloud. For example, `//foo/bar` means a file or directory named `bar` within a directory named `foo` in the root of the hscloud repository.
+
+These paths are notably used by Bazel, our build system, which extends the syntax slightly: `//foo/bar:baz` means a file or *build target* named baz within a directory bar within a diredctory foo inside hscloud.
+
+What are changes?
+-----------------
+
+Changes, or Change Requests, or Change Lists (all used interchangably) are modifications to the hscloud repository. Each change corresponds to a single Git commit that has been reviewed on [gerrit, our code review tool](https://gerrit.hackerspace.pl). Each change can be identified by:
+
+ - The git commit hash when it got merged into `master` (eg. `146c99e58e46f3c026e170794326521de6bf13e1`)
+ - The ChangeId hash within the git commit message (eg. `I8b64103cb87d8b185ff35165695a18cb19fea523`)
+ - The Change number on gerrit (eg. [https://gerrit.hackerspace.pl/841](https://gerrit.hackerspace.pl/841))
+
+Going from Change number to git commit hash or ChangeId is easy - gerrit will show these to you when you visit the page for any given Change number. Going the otherway is currently not super intuitive - you have to use Gerrit's web interface to search for either git commit hash or ChangeId.
+
+Viewing hscloud online
+----------------------
+
+We have a **code search** tool/web interface available on [https://cs.hackerspace.pl/hscloud](https://cs.hackerspace.pl/hscloud), which is probably the most convenient way to browse hscloud online, and link it to others. Gerrit also has its own built-in code viewer (Gitiles), but its use is being phased out.
+
+So what's in hscloud? Where do I start?
+---------------------------------------
+
+If you want to modify something, you'll first have to figure out where is it in hscloud. For that, either use codesearch, or ask any of the people who work on hscloud for guidance. There is also a somewhat up-to-date directory of interesting top-level things at [//README.md](/README.md).
+
+Once you have that, look at other [codelabs](../) for guidance on working with specific technologies within hscloud. If something's missing - ask someone knowledgeable to either document it, or help you document it!
+
+Where do we report and track issues?
+------------------------------------
+
+We have an issue tracker at [https://issues.hackerspace.pl/](https://issues.hackerspace.pl/). It's a Redmine instance so it's not super friendly, so we made some shorthand links:
+
+ - [https://b.hswaw.net](https://b.hswaw.net) will take you to a page with you personal dashboard.
+ - [https://b.hswaw.net/123](https://b.hswaw.net/123) will take you to issue number 123.
+ - [https://b.hswaw.net/new](https://b.hswaw.net/new) will take you to a page to file a new issue.
+
+**File issues often, file issues early, ask for forgiveness later!** It's much easier to keep track of things on there than to chat up people. Don't worry about categorizing, and feel free to assign q3k for anything.
+
+You can naturally also ask on #members:hackerspace.pl or even #general:hackerspace.pl - but if you encounter an problem, we highly recommend filing issues instead of chatting people up :).
+
+Further steps
+=============
+
+Now that you have a local hscloud checkout and mostly know why it exists and how it's laid out, there's a few next steps you can take:
+
+ - [Your first hscloud Change](your-first-change.md) will guide you through using Gerrit to send your first contribution to hscloud - a small file change in your personal directory.
+ - **TODO** will guide you through building some code using Bazel, and then writing a tiny bit of Go code of your own.
+ - **TODO** will guide you through accessing our production Kubernetes cluster and running some code on it.
diff --git a/doc/codelabs/getting-started/your-first-change.md b/doc/codelabs/getting-started/your-first-change.md
index 93990d0..d0b6fad 100644
--- a/doc/codelabs/getting-started/your-first-change.md
+++ b/doc/codelabs/getting-started/your-first-change.md
@@ -8,9 +8,7 @@
 Prerequisites
 -------------
 
-hscloud is a git repository. If you're new to git, this might be slightly confusing to you, but you should still be able to follow along. In case you need brush up on that, links to relevant resources await you at the bottom of this document.
-
-As for software, you'll need to have git installed on your workstation.
+**Required**: [Checking out hscloud](checking-out.md) to understand the basics of the hscloud code structure.
 
 Gerrit concepts
 ---------------
@@ -65,10 +63,19 @@
 
 Note that you should be using your Warsaw Hackerspace SSO user name. If that's different from your local workstation username, you'll have to specify `user@gerrit.hackerspace.pl` in all the following commands.
 
-Cloning hscloud and looking around
-----------------------------------
+Connecting your hscloud checkout to Gerrit
+------------------------------------------
 
-Now, clone hscloud and take a look around:
+If you've come here from the [Checking out hscloud](checking-out.md) codelab, you probably already have a read-only hscloud checkout over HTTPS. If so, you will have to switch its remote to access Gerrit through your SSH key:
+
+    $ cd hscloud
+    $ git remote remove origin
+    $ # If your local username is the same as your SSO username:
+    $ git remote add origin gerrit.hackerspace.pl:hscloud
+    $ # Otherwise, you'll have to specify your SSO username explicitly:
+    $ git remote add origin user@gerrit.hackerspace.pl:hscloud
+
+Or, **if you don't have a hscloud checkout on hand**, just clone it from scratch using the authenticated Gerrit endpoint:
 
     $ git clone gerrit.hackerspace.pl:hscloud
     Cloning into 'hscloud'...
@@ -78,20 +85,11 @@
     Receiving objects: 100% (5469/5469), 15.25 MiB | 14.08 MiB/s, done.
     Resolving deltas: 100% (2686/2686), done.
     $ cd hscloud
-    $ ls
-    app  bgpwtf  BUILD  bzl  cluster  COPYING  dc  devtools  doc  env.fish  env.sh  gcp  go  hackdoc.toml  hswaw  kube  OWNERS  personal  README.md  third_party  tools  WORKSPACE
-    $
-
-If you've already cloned hscloud before (eg. over https), either clone it again or update your origin to point at `user@gerrit.hackerspace.pl:hscloud`.
-
-You can also use Gitiles (a part of Gerrit) to view hscloud in your web browser: https://gerrit.hackerspace.pl/plugins/gitiles/hscloud/+/refs/heads/master
-
-Any time you see a `//path/written/like/this`, `//` refers to the root of the hscloud checkout. For example, `//devtools/gerrit` contains all code related to our Gerrit instance. If you want to learn more about hscloud's directory structure, start at [//README.md](/README.md).
 
 Configuring git for Gerrit
 --------------------------
 
-There's just a bit more of one-time setup that you'll need to do in order to send changes to Gerrit:
+You now have hscloud cloned from an authenticated Gerrit endpoint. Gerrit knows it's you trying to access (and/or write) code. However, there's just a bit more of one-time setup that you'll need to do in order to send changes to Gerrit:
 
     $ git config user.email 'user@hackerspace.pl' # replace user with your SSO login name
     $ curl -Lo .git/hooks/commit-msg https://gerrit.hackerspace.pl/tools/hooks/commit-msg
@@ -190,6 +188,6 @@
 -------------
 
  - If you need a **Git refresher** - we highly recommend the [Git Visual Reference](https://marklodato.github.io/visual-git-guide/index-en.html)
- - While this codelab showed you how to create and submit CRs, you didn't see anything about code review. Watch this space for a codelab about that.
- - You should now be able to commit and change code. Watch this space for a link to a codelab on using Bazel or writing a simple microservice in Go.
+ - **TODO** will guide you through building some code using Bazel, and then writing a tiny bit of Go code of your own.
+ - **TODO** will guide you through accessing our production Kubernetes cluster and running some code on it.
 
diff --git a/doc/codelabs/index.md b/doc/codelabs/index.md
index 790447f..1d6dea5 100644
--- a/doc/codelabs/index.md
+++ b/doc/codelabs/index.md
@@ -8,4 +8,5 @@
 Getting started
 ---------------
 
+- [**Checking out hscloud**](getting-started/checking-out.md) - how to get a copy of hscloud, how to navigate around it, and what's what. This is nearly **mandatory** for anyone who wishes to interact with hscloud.
 - [**Your First Change**](getting-started/your-first-change.md) - how to use Gerrit and git to send your first change to hscloud, and an intro to personal directories. Using Gerrit can be somewhat confusing even (or especially) if you're used to Gitflow or GitHub.
diff --git a/games/valheim/OWNERS b/games/valheim/OWNERS
new file mode 100644
index 0000000..7b3f74b
--- /dev/null
+++ b/games/valheim/OWNERS
@@ -0,0 +1,4 @@
+owners:
+- q3k
+- palid
+- patryk
diff --git a/games/valheim/README.md b/games/valheim/README.md
new file mode 100644
index 0000000..cf02ce5
--- /dev/null
+++ b/games/valheim/README.md
@@ -0,0 +1,10 @@
+Valheim
+=======
+
+This is our (tiny) Valheim game server infrastructure. We run it on prod on k0, using an existing Docker image.
+
+Updating
+--------
+
+    kubecfg update prod.jsonnet
+
diff --git a/games/valheim/prod.jsonnet b/games/valheim/prod.jsonnet
new file mode 100644
index 0000000..9f0f421
--- /dev/null
+++ b/games/valheim/prod.jsonnet
@@ -0,0 +1,183 @@
+local kube = import "../../kube/kube.libsonnet";
+
+{
+    local top = self,
+    env(ns, name):: {
+        local env = self,
+        local cfg = env.cfg,
+        cfg:: {
+            name: name,
+            displayName: name,
+            image: "mbround18/valheim:latest",
+            password: error "password must be set",
+            storageClassName: "waw-hdd-redundant-3",
+            port: 2456,
+        },
+
+        local named = function(component) "%s-%s" % [name, component],
+
+        game: {
+            local game = self,
+            pvcs: {
+                backups: ns.Contain(kube.PersistentVolumeClaim(named("backups"))) {
+                    spec+: {
+                        storageClassName: cfg.storageClassName,
+                        accessModes: ["ReadWriteOnce"],
+                        resources: {
+                            requests: { storage: "10Gi" },
+                        },
+                    },
+                },
+                saves: ns.Contain(kube.PersistentVolumeClaim(named("saves"))) {
+                    spec+: {
+                        storageClassName: cfg.storageClassName,
+                        accessModes: ["ReadWriteOnce"],
+                        resources: {
+                            requests: { storage: "10Gi" },
+                        },
+                    },
+                },
+                server: ns.Contain(kube.PersistentVolumeClaim(named("server"))) {
+                    spec+: {
+                        storageClassName: cfg.storageClassName,
+                        accessModes: ["ReadWriteOnce"],
+                        resources: {
+                            requests: { storage: "10Gi" },
+                        },
+                    },
+                },
+            },
+            svc: ns.Contain(kube.Service(named("external"))) {
+                target_pod:: game.deployment.spec.template,
+                spec+: {
+                    ports: kube.mapToNamedList({
+                        zero: { port: cfg.port, targetPort: cfg.port, protocol: "UDP" },
+                        one: { port: cfg.port+1, targetPort: cfg.port+1, protocol: "UDP" },
+                        two: { port: cfg.port+2, targetPort: cfg.port+2, protocol: "UDP" },
+                    }),
+                    type: "LoadBalancer",
+                },
+            },
+
+            scripts: ns.Contain(kube.ConfigMap(named("scripts"))) {
+                data: {
+                    # Based on https://github.com/mbround18/valheim-docker ,
+                    # removed all reliance on running as root (thus removed
+                    # autoupdater/autobackups).
+                    "entrypoint.sh": |||
+                        #!/usr/bin/env bash
+                        log() {
+                            PREFIX="[entrypoint]"
+                            printf "%-16s: %s\n" "${PREFIX}" "$1"
+                        }
+                        line() {
+                            log "==========================================================================="
+                        }
+                        setup_filesystem() {
+                            log "Setting up file systems"
+                            mkdir -p /home/steam/valheim
+                            mkdir -p /home/steam/valheim/logs
+                            mkdir -p /home/steam/backups
+                            mkdir -p /home/steam/scripts
+                            mkdir -p /home/steam/valheim
+                            cp /home/steam/steamcmd/linux64/steamclient.so /home/steam/valheim
+                        }
+                        line
+                        log "Valheim Server - $(date)"
+                        log "Initializing your container..."
+                        line
+                        setup_filesystem
+                        log "Launching the rest of the fucking owl"
+                        cd /home/steam/valheim || exit 1
+                        exec "$@"
+                    |||
+                },
+            },
+            secret: ns.Contain(kube.Secret(named("game"))) {
+                data_: {
+                    # public game password
+                    public: cfg.password,
+                },
+            },
+            deployment: ns.Contain(kube.Deployment(named("game"))) {
+                spec+: {
+                    template+: {
+                        spec+: {
+                            containers_: {
+                                default: kube.Container("default") {
+                                    image: cfg.image,
+                                    command: [
+                                        "/bin/bash", "/scripts/entrypoint.sh", "/home/steam/scripts/start_valheim.sh",
+                                    ],
+                                    volumeMounts_: {
+                                        backups: { mountPath: "/home/steam/backups" },
+                                        saves: { mountPath: "/home/steam/.config/unity3d/IronGate/Valheim" },
+                                        server: { mountPath: "/home/steam/valheim" },
+                                        scripts: { mountPath: "/scripts" },
+                                    },
+                                    ports_: {
+                                        zero: { containerPort: cfg.port },
+                                        one: { containerPort: cfg.port + 1 },
+                                        two: { containerPort: cfg.port + 2 },
+                                    },
+                                    env_: {
+                                        PUBLIC: "1",
+                                        PASSWORD: kube.SecretKeyRef(game.secret, "public"),
+                                        NAME: cfg.displayName,
+                                        # Always attempt to update valheim on startup.
+                                        FORCE_INSTALL: "1",
+                                    },
+                                    resources: {
+                                        requests: {
+                                            cpu: "500m",
+                                            memory: "2Gi",
+                                        },
+                                        limits: {
+                                            cpu: "1000m",
+                                            memory: "4Gi",
+                                        },
+                                    },
+                                },
+                            },
+                            securityContext: {
+                                runAsUser: 1000,
+                                runAsGroup: 1000,
+                                fsGroup: 1000,
+                            },
+                            volumes_: {
+                                backups: kube.PersistentVolumeClaimVolume(game.pvcs.backups),
+                                saves: kube.PersistentVolumeClaimVolume(game.pvcs.saves),
+                                server: kube.PersistentVolumeClaimVolume(game.pvcs.server),
+                                scripts: kube.ConfigMapVolume(game.scripts),
+                            },
+                        },
+                    },
+                },
+            },
+        },
+    },
+
+    # Make namespace for valheim.
+    ns: kube.Namespace("valheim"),
+
+    # Allow patryk and palid to administer this namespace via the namespace-admin clusterrole.
+    adminRB: top.ns.Contain(kube.RoleBinding("sso:admins")) {
+        subjects: [
+            { apiGroup: "rbac.authorization.k8s.io", kind: "User", name: "%s@hackerspace.pl" % [u] }
+            for u in ["patryk", "palid"]
+        ],
+        roleRef: {
+            apiGroup: "rbac.authorization.k8s.io",
+            kind: "ClusterRole",
+            name: "system:admin-namespace",
+        },
+    },
+
+    q3k: top.env(top.ns, "q3k") {
+        cfg+: {
+            ns: "valheim",
+            password: (std.split(importstr "secrets/plain/q3k-public", "\n"))[0],
+            displayName: "wypierdol z polski xD",
+        },
+    },
+}
diff --git a/games/valheim/secrets/cipher/q3k-public b/games/valheim/secrets/cipher/q3k-public
new file mode 100644
index 0000000..b95f2a8
--- /dev/null
+++ b/games/valheim/secrets/cipher/q3k-public
@@ -0,0 +1,39 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+=YmoD
+-----END PGP MESSAGE-----
diff --git a/games/valheim/secrets/plain/.gitignore b/games/valheim/secrets/plain/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/games/valheim/secrets/plain/.gitignore
@@ -0,0 +1 @@
+*
diff --git a/hswaw/README.md b/hswaw/README.md
index cb11d17..6e4ad2f 100644
--- a/hswaw/README.md
+++ b/hswaw/README.md
@@ -2,3 +2,5 @@
 =============
 
 Services and systems related to the Warsaw Hackerspace (ie. the physical place, not its cloud/ISP infrastructure).
+
+ - [oodviewer](oodviewer/), a spartan web interface to access our IRC bots' memory
diff --git a/hswaw/cebulacamp/landing/BUILD.bazel b/hswaw/cebulacamp/landing/BUILD.bazel
new file mode 100644
index 0000000..e2f09e0
--- /dev/null
+++ b/hswaw/cebulacamp/landing/BUILD.bazel
@@ -0,0 +1,34 @@
+load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_push")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//extras:embed_data.bzl", "go_embed_data")
+
+go_embed_data(
+    name = "static",
+    srcs = ["index.html", "style/main.css", "cebula2020.jpeg", "hotel-orle.jpg"],
+    package = "static",
+)
+
+# keep
+go_library(
+    name = "static_go",
+    srcs = [":static"],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/cebulacamp/landing/static",
+    visibility = ["//visibility:public"],
+)
+
+container_image(
+    name="latest",
+    base="@prodimage-bionic//image",
+    files = ["//hswaw/cebulacamp/landing/backend:backend"],
+    directory = "/hscloud/hswaw/cebulacamp/landing",
+    entrypoint = ["/hscloud/hswaw/cebulacamp/landing/backend"],
+)
+
+container_push(
+    name = "push",
+    image = ":latest",
+    format = "Docker",
+    registry = "registry.k0.hswaw.net",
+    repository = "q3k/cebulacamp-landing",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/hswaw/cebulacamp/landing/LICENSE.md b/hswaw/cebulacamp/landing/LICENSE.md
new file mode 100644
index 0000000..5c93f45
--- /dev/null
+++ b/hswaw/cebulacamp/landing/LICENSE.md
@@ -0,0 +1,13 @@
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+                    Version 2, December 2004
+
+ Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. You just DO WHAT THE FUCK YOU WANT TO.
diff --git a/hswaw/cebulacamp/landing/backend/BUILD.bazel b/hswaw/cebulacamp/landing/backend/BUILD.bazel
new file mode 100644
index 0000000..8840951
--- /dev/null
+++ b/hswaw/cebulacamp/landing/backend/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/cebulacamp/landing/backend",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@com_github_golang_glog//:go_default_library",
+        "//hswaw/cebulacamp/landing:static_go", # keep
+    ],
+)
+
+go_binary(
+    name = "backend",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["main_test.go"],
+    embed = [":go_default_library"],
+)
diff --git a/hswaw/cebulacamp/landing/backend/main.go b/hswaw/cebulacamp/landing/backend/main.go
new file mode 100644
index 0000000..1416d07
--- /dev/null
+++ b/hswaw/cebulacamp/landing/backend/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"mime"
+	"net/http"
+	"strings"
+
+	"github.com/golang/glog"
+
+	"code.hackerspace.pl/hscloud/hswaw/cebulacamp/landing/static"
+)
+
+var (
+	flagBind string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+func main() {
+	flag.StringVar(&flagBind, "bind", "0.0.0.0:8080", "Address at which to serve HTTP requests")
+	flag.Parse()
+	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		path := r.URL.Path
+		if path == "/" {
+			path = "/index.html"
+		}
+		path = strings.TrimPrefix(path, "/")
+		staticPath := fmt.Sprintf("hswaw/cebulacamp/landing/%s", path)
+		if data, ok := static.Data[staticPath]; ok {
+			parts := strings.Split(path, ".")
+			ext := fmt.Sprintf(".%s", parts[len(parts)-1])
+			t := mime.TypeByExtension(ext)
+			w.Header().Set("Content-Type", t)
+			w.Write(data)
+		} else {
+			http.NotFound(w, r)
+		}
+	})
+
+	glog.Infof("Starting up at %v", flagBind)
+	err := http.ListenAndServe(flagBind, nil)
+	if err != nil {
+		glog.Exit(err)
+	}
+}
diff --git a/hswaw/cebulacamp/landing/cebula2020.jpeg b/hswaw/cebulacamp/landing/cebula2020.jpeg
new file mode 100644
index 0000000..5794856
--- /dev/null
+++ b/hswaw/cebulacamp/landing/cebula2020.jpeg
Binary files differ
diff --git a/hswaw/cebulacamp/landing/hotel-orle.jpg b/hswaw/cebulacamp/landing/hotel-orle.jpg
new file mode 100644
index 0000000..43638dc
--- /dev/null
+++ b/hswaw/cebulacamp/landing/hotel-orle.jpg
Binary files differ
diff --git a/hswaw/cebulacamp/landing/index.html b/hswaw/cebulacamp/landing/index.html
new file mode 100644
index 0000000..b2d4e95
--- /dev/null
+++ b/hswaw/cebulacamp/landing/index.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Cebulacamp 2020</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="stylesheet" type="text/css" href="style/main.css">
+<header class="hero">
+    <h1>Kongres Komunikacyjny Cebula 2021</h1>
+    <h2>Testing in <s>production</s> pandemic</h2>
+    <p class="acc0">2021/xx/xx - 2021/xx/xx</p>
+    <p class="acc1">Hotel Orle, Gdańsk</p>
+    <p>
+        Three days of under-organized hacking and talking in a hotel in northern Poland. We might talk about the Polish hacker scene. We might get technical. We might even speak mostly Polish, but we'll do our best to welcome people who don't speak encrypted. We're friendly.
+    </p>
+    <p class="acc2">
+        Happening any day now! Hopefully in 2021. Waiting for vaccines.
+    </p>
+</header>
+<main>
+    <section class="starter">
+        <img src="cebula2020.jpeg">
+    </section>
+    <section>
+        <div class="section-content">
+            <header>
+                <h2>Tickets</h2>
+                <p>
+                    Get your tickets now at <a href="https://tickets.hackerspace.pl/cebulacamp/kkc20/1">tickets.hackerspace.pl/cebulacamp/kkc20/1</a>.
+                </p>
+                <p>
+                    An all-inclusive ticket (bed in shared room for two nights, food for three days) is 500 PLN. If you want a solo room, or to share a room with someone in particular, contact <a href="mailto:cebula@hackerspace.pl">cebula@hackerspace.pl</a>, or (once you bought your ticket) <a href="mailto:info@orle.pl">info@orle.com.pl</a>.
+                </p>
+                <p>
+                    <img class="hotel-pic" src="hotel-orle.jpg">
+                </p>
+            </header>
+        </div>
+    </section>
+    <section>
+        <div class="section-content">
+            <header>
+                <h2>CFP</h2>
+                <p>
+                    Yes, there will be some talks. No, they won't be very formal.
+                </p>
+                <p>
+                    Please take part in Cebula Camp! <a href="https://cfp.cebula.camp/kkc-2020/cfp">Submit your talks</a>, or, <a href="https://cfp.cebula.camp/kkc-2020/sneak/">Look at Sneak Peaks</a>.
+                </p>
+            </header>
+        </div>
+    </section>
+    <section>
+        <div class="section-content">
+            <header>
+                <h2>Contact</h2>
+                <p>
+                    Reach the organizers at <a href="mailto:cebula@hackerspace.pl">cebula@hackerspace.pl</a>. Reach the hotel (in case of room requests, etc) at <a href="mailto:info@orle.com.pl">info@orle.com.pl</a>, and CC cebula@hackerspace.pl if you so with.
+                </p>
+                <p>
+                    irc: #cebulacamp on Freenode
+                </p>
+                <p>
+                    Cebula Camp is an inclusive event. Be excellent to each other, or stay home. Harassment and discrimination are not welcome or tolerated, online or AFK. If you're a subject, observer, or third-party to any of these, please, get in touch, write to <a href="mailto:cebula@hackerspace.pl">cebula@hackerspace.pl</a> or <a href="mailto:q3k@q3k.org">q3k personally</a>.
+                </p>
+            </header>
+        </div>
+    </section>
+</main>
diff --git a/hswaw/cebulacamp/landing/style/main.css b/hswaw/cebulacamp/landing/style/main.css
new file mode 100644
index 0000000..5668e45
--- /dev/null
+++ b/hswaw/cebulacamp/landing/style/main.css
@@ -0,0 +1,148 @@
+.hero {
+    color: rgba(223, 219, 244, 1);;
+    background-color: rgba(0, 0, 0, 1);;
+    text-align: center;
+    padding: 5em;
+}
+
+@media only screen and (max-width: 1281px) {
+    .hero {
+        padding: 1em !important;
+    }
+    main {
+        width: 90% !important;
+    }
+    section {
+        margin: 0 0 1em 0 !important;
+        padding: 0em !important;
+    }
+    .hotel-pic {
+        width: 100% !important;
+    }
+}
+
+* {
+    box-sizing: border-box;
+    /* TODO: find what's causing weird top belt */
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    background-color: rgba(0, 0, 0, 1);;
+}
+
+main {
+    width: 60%;
+    margin: 0 auto;
+}
+
+/* TODO: Maybe some fun with how text is laid */
+
+section.starter {
+    border: none !important;
+    padding: 1em;
+}
+
+section.starter img {
+    width: 100%
+}
+
+section {
+    color: rgba(223, 219, 244, 1);;
+    /* TODO: have some fun with transparency :D */
+    background-color: rgba(0, 0, 0, 1);;
+    padding-bottom: 5em;
+    word-wrap: break-word;
+    padding: 3em;
+}
+
+section:nth-child(2) {
+    margin: 0em 1em 0em -2em;
+}
+
+section:nth-child(3) {
+    margin: -3em -1em 3em 1em;
+}
+
+section:nth-child(4) {
+    margin: -6em -3em 6em 4em;
+}
+
+section:nth-child(5) {
+    margin: -9em -5em 9em 7em;
+}
+
+section:nth-child(6) {
+    margin: -12em -7em 12em 10em;
+}
+
+section:nth-child(7) {
+    margin: -15em -9em 15em 13em;
+}
+
+section p {
+    margin: 1em;
+}
+
+section h2 {
+    display: inline-block;
+    margin: 1em 1em 1em 0em;
+    padding: .5em;
+}
+
+section:nth-child(even) {
+    border: 8px solid rgba(173, 35, 101, 1);;
+}
+
+section:nth-child(even) h2 {
+    background-color: rgba(173, 35, 101, 1);;
+}
+
+section:nth-child(even) a {
+    color: rgba(173, 35, 101, 1);;
+}
+
+section:nth-child(even) a:hover {
+   color: rgba(61, 140, 208, 1);;
+}
+
+section:nth-child(odd) {
+    border: 8px solid rgba(61, 140, 208, 1);;
+}
+
+section:nth-child(odd) h1 {
+    background-color: rgba(61, 140, 208, 1);;
+}
+
+section:nth-child(odd) a {
+    color: rgba(61, 140, 208, 1);;
+}
+
+section:nth-child(odd) a:hover {
+    color: rgba(173, 35, 101, 1);;
+}
+
+/* testing area */
+.acc0 {
+    color: rgba(61, 140, 208, 1);;
+}
+
+.acc1 {
+    color: rgba(232, 227, 33, 1);;
+}
+
+.acc2 {
+    color: rgba(173, 35, 101, 1);;
+}
+
+.hotel-pic
+{
+    width: 50%;
+    display: block;
+    margin: .5em auto;
+    width: 50%;
+}
diff --git a/hswaw/kube/cebulacamp.libsonnet b/hswaw/kube/cebulacamp.libsonnet
new file mode 100644
index 0000000..3380bdf
--- /dev/null
+++ b/hswaw/kube/cebulacamp.libsonnet
@@ -0,0 +1,29 @@
+local mirko = import "../../kube/mirko.libsonnet";
+local kube = import "../../kube/kube.libsonnet";
+
+{
+    cfg:: {
+        image: "registry.k0.hswaw.net/q3k/cebulacamp-landing:315532800-f25fd84f02caf48122babfbd24acb3ce8a7979b0",
+        webFQDN: error "webhookFQDN must be set",
+    },
+
+    component(cfg, env):: mirko.Component(env, "cebulacamp") {
+        local cebulacamp = self,
+        cfg+: {
+            image: cfg.image,
+            container: cebulacamp.Container("main") {
+                command: [
+                    "/hscloud/hswaw/cebulacamp/landing/backend",
+                ],
+            },
+            ports+: {
+                publicHTTP: {
+                    web: {
+                        port: 8080,
+                        dns: cfg.webFQDN,
+                    }
+                },
+            },
+        },
+    },
+}
diff --git a/hswaw/kube/hswaw.jsonnet b/hswaw/kube/hswaw.jsonnet
index 806a018..41ff73d 100644
--- a/hswaw/kube/hswaw.jsonnet
+++ b/hswaw/kube/hswaw.jsonnet
@@ -6,6 +6,7 @@
 local teleimg = import "teleimg.libsonnet";
 local frab = import "frab.libsonnet";
 local pretalx = import "pretalx.libsonnet";
+local cebulacamp = import "cebulacamp.libsonnet";
 
 {
     hswaw(name):: mirko.Environment(name) {
@@ -18,6 +19,7 @@
             teleimg: teleimg.cfg,
             frab: frab.cfg,
             pretalx: pretalx.cfg,
+            cebulacamp: cebulacamp.cfg,
         },
 
         components: {
@@ -27,6 +29,7 @@
             lelegram: teleimg.lelegram(cfg.teleimg, env),
             frab: frab.component(cfg.frab, env),
             pretalx: pretalx.component(cfg.pretalx, env),
+            cebulacamp: cebulacamp.component(cfg.cebulacamp, env),
         },
     },
 
@@ -63,6 +66,9 @@
                     credsSecret: import "secrets/plain/prod-pretalx-s3.json",
                 },
             },
+            cebulacamp+: {
+                webFQDN: "cebula.camp",
+            },
         },
     },
 }
diff --git a/hswaw/oodviewer/BUILD.bazel b/hswaw/oodviewer/BUILD.bazel
new file mode 100644
index 0000000..607780d
--- /dev/null
+++ b/hswaw/oodviewer/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 = [
+        "app.go",
+        "main.go",
+        "views.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/oodviewer",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//hswaw/oodviewer/templates:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_lib_pq//:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "oodviewer",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":oodviewer",
+    ],
+    directory = "/hswaw/",
+)
+
+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 = "q3k/oodviewer",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/hswaw/oodviewer/OWNERS b/hswaw/oodviewer/OWNERS
new file mode 100644
index 0000000..9f23bcb
--- /dev/null
+++ b/hswaw/oodviewer/OWNERS
@@ -0,0 +1,3 @@
+owners:
+- ar
+- q3k
diff --git a/hswaw/oodviewer/README.md b/hswaw/oodviewer/README.md
new file mode 100644
index 0000000..b609c91
--- /dev/null
+++ b/hswaw/oodviewer/README.md
@@ -0,0 +1,28 @@
+Oodviewer
+=========
+
+Spartan web interface for the term database of our IRC bot (ood/oof/klacz).
+
+Go rewrite of a shitty old Python script that q3k wrote and hosted on his own infra. Now productionized!
+
+Building and Running
+--------------------
+
+    bazel build //hswaw/oodviewer
+    bazel run //hswaw/oodviewer -- -postgres 'postgres://ood:password@host/ood'
+
+Production deployment
+---------------------
+
+Runs on k0, connects to ood's database on boston. Serves from https://oodviewer.q3k.me/.
+
+To deploy:
+
+    bazel run //hswaw/oodviewer:push
+    # update //hswaw/oodviewer/prod.jsonnet with new image name
+    kubecfg update prod.jsonnet
+
+Development
+-----------
+
+Beg and borrow ood admins for psql credentials. Keep in mind that you will not be able to access the production database over the Internet - either develop on Boston or run a port forward over SSH.
diff --git a/hswaw/oodviewer/app.go b/hswaw/oodviewer/app.go
new file mode 100644
index 0000000..afe6bc8
--- /dev/null
+++ b/hswaw/oodviewer/app.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/lib/pq"
+)
+
+// app is the model of the oodviewer app.
+// The data modeled is a K/V map from string ('Term') to list of entries.
+type app struct {
+	db *sql.DB
+}
+
+// term represents a key in the K/V map of the model.
+type term struct {
+	// Name of the term, the 'K' of the K/V map.
+	Name string
+	// Count of entries (len(V) of the K/V map).
+	Entries uint64
+}
+
+// entry is an element contained under a term. A list of entries ([]entry) is
+// the 'V' of the K/V map.
+type entry struct {
+	Entry  string `json:"entry"`
+	Added  int64  `json:"added"`
+	Author string `json:"author"`
+}
+
+// newApp returns an instantiated app given a lib/pq postgres connection
+// string.
+func newApp(postgres string) (*app, error) {
+	db, err := sql.Open("postgres", flagPostgres)
+	if err != nil {
+		return nil, fmt.Errorf("Open: %v", err)
+	}
+
+	return &app{
+		db: db,
+	}, nil
+}
+
+// getTerms returns all terms stored in the database.
+func (a *app) getTerms(ctx context.Context) ([]term, error) {
+	rows, err := a.db.QueryContext(ctx, `
+		SELECT
+			_term._name,
+			count(_entry._text)
+		FROM
+			_term
+		LEFT JOIN _entry
+		ON
+			_entry._term_oid = _term._oid
+		GROUP BY _term._oid
+		ORDER BY _term._name
+	`)
+	if err != nil {
+		return nil, err
+	}
+	var res []term
+	for rows.Next() {
+		var name string
+		var count uint64
+		if err := rows.Scan(&name, &count); err != nil {
+			return nil, err
+		}
+		res = append(res, term{
+			Name:    name,
+			Entries: count,
+		})
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return res, err
+}
+
+// getEntries returns all entries of a given term stored in the database.
+func (a *app) getEntries(ctx context.Context, name string) ([]entry, error) {
+	rows, err := a.db.QueryContext(ctx, `
+		SELECT
+			_entry._text,
+			_entry._added_at,
+			_entry._added_by
+		FROM
+			_term
+		LEFT JOIN _entry
+		ON _entry._term_oid = _term._oid
+		WHERE lower(_term._name) = lower($1)
+		ORDER BY _entry._added_at
+	`, name)
+	if err != nil {
+		return nil, err
+	}
+	var res []entry
+	for rows.Next() {
+		var text string
+		var added time.Time
+		var author string
+		if err := rows.Scan(&text, &added, &author); err != nil {
+			return nil, err
+		}
+		res = append(res, entry{
+			Entry:  text,
+			Added:  added.Unix(),
+			Author: author,
+		})
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return res, err
+}
diff --git a/hswaw/oodviewer/main.go b/hswaw/oodviewer/main.go
new file mode 100644
index 0000000..27fb2e0
--- /dev/null
+++ b/hswaw/oodviewer/main.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"math/rand"
+	"net/http"
+	"time"
+
+	"github.com/golang/glog"
+)
+
+var (
+	flagPostgres string
+	flagListen   string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+func handleRobots(w http.ResponseWriter, r *http.Request) {
+	// Prevent indexing by any (honest) search engine.
+	fmt.Fprintf(w, "User-agent: *\nDisallow: /\n")
+}
+
+func main() {
+	flag.StringVar(&flagPostgres, "postgres", "", "Postgres connection string (see lib/pq docs)")
+	flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "Address to listen at for public HTTP traffic")
+	flag.Parse()
+
+	rand.Seed(time.Now().Unix())
+
+	a, err := newApp(flagPostgres)
+	if err != nil {
+		glog.Exitf("newApp: %v", err)
+	}
+
+	http.HandleFunc("/robots.txt", handleRobots)
+
+	http.HandleFunc("/terms.json", a.handleTermsJson)
+	http.HandleFunc("/term.json/", a.handleTermJson)
+	http.HandleFunc("/randomterm.json/", a.handleRandomTermJson)
+
+	http.HandleFunc("/terms", a.handleTerms)
+	http.HandleFunc("/", a.handleTerms)
+
+	http.HandleFunc("/term/", a.handleTerm)
+
+	glog.Infof("Listening at %q", flagListen)
+	if err := http.ListenAndServe(flagListen, nil); err != nil {
+		glog.Exit(err)
+	}
+}
diff --git a/hswaw/oodviewer/prod.jsonnet b/hswaw/oodviewer/prod.jsonnet
new file mode 100644
index 0000000..914264b
--- /dev/null
+++ b/hswaw/oodviewer/prod.jsonnet
@@ -0,0 +1,85 @@
+// Production deployment of oodviewer.q3k.me.
+//
+// See README.md for more information.
+
+local kube = import "../../kube/kube.libsonnet";
+
+{
+    local top = self,
+    local cfg = self.cfg,
+    ns: kube.Namespace("oodviewer-prod"),
+
+    cfg:: {
+        dbUser: "ood",
+        dbPass: std.split(importstr "secrets/plain/postgres-pass", "\n")[0],
+        dbHost: "hackerspace.pl",
+        dbName: "ood",
+        postgresConnectionString: "postgres://%s:%s@%s/%s?sslmode=disable" % [cfg.dbUser, cfg.dbPass, cfg.dbHost, cfg.dbName],
+
+        image: "registry.k0.hswaw.net/q3k/oodviewer:315532800-5cd20075113e74d0a69f501c74db766cba597662",
+        domain: "oodviewer.q3k.me",
+    },
+
+    secret: top.ns.Contain(kube.Secret("oodviewer")) {
+        data_: {
+            "postgres": cfg.postgresConnectionString,
+        },
+    },
+
+    deploy: top.ns.Contain(kube.Deployment("oodviewer")) {
+        spec+: {
+            replicas: 3,
+            template+: {
+                spec+: {
+                    containers_: {
+                        default: kube.Container("default") {
+                            image: cfg.image,
+                            command: [
+                                "/hswaw/oodviewer",
+                                "-listen", "0.0.0.0:8080",
+                                "-postgres", "$(POSTGRES)",
+                            ],
+                            env_: {
+                                POSTGRES: kube.SecretKeyRef(top.secret, "postgres"),
+                            },
+                            resources: {
+                                requests: { cpu: "0.01", memory: "64M" },
+                                limits: { cpu: "1", memory: "256M" },
+                            },
+                            ports_: {
+                                http: { containerPort: 8080 },
+                            },
+                        },
+                    },
+                },
+            },
+        },
+    },
+
+    service: top.ns.Contain(kube.Service("oodviewer")) {
+        target_pod:: top.deploy.spec.template,
+    },
+
+    ingress: top.ns.Contain(kube.Ingress("oodviewer")) {
+        metadata+: {
+            annotations+: {
+                "kubernetes.io/tls-acme": "true",
+                "certmanager.k8s.io/cluster-issuer": "letsencrypt-prod",
+                "nginx.ingress.kubernetes.io/proxy-body-size": "0",
+            },
+        },
+        spec+: {
+            tls: [ { hosts: [ cfg.domain ], secretName: "oodviewer-tls" } ],
+            rules: [
+                {
+                    host: cfg.domain,
+                    http: {
+                        paths:  [
+                            { path: "/", backend: top.service.name_port },
+                        ],
+                    },
+                },
+            ],
+        },
+    }
+}
diff --git a/hswaw/oodviewer/secrets/cipher/postgres-pass b/hswaw/oodviewer/secrets/cipher/postgres-pass
new file mode 100644
index 0000000..9342a72
--- /dev/null
+++ b/hswaw/oodviewer/secrets/cipher/postgres-pass
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+hdsApEHzDw==
+=nmyr
+-----END PGP MESSAGE-----
diff --git a/hswaw/oodviewer/secrets/plain/.gitignore b/hswaw/oodviewer/secrets/plain/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/hswaw/oodviewer/secrets/plain/.gitignore
@@ -0,0 +1 @@
+*
diff --git a/hswaw/oodviewer/templates/BUILD.bazel b/hswaw/oodviewer/templates/BUILD.bazel
new file mode 100644
index 0000000..be98820
--- /dev/null
+++ b/hswaw/oodviewer/templates/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//extras:embed_data.bzl", "go_embed_data")
+
+go_embed_data(
+    name = "templates_data",
+    srcs = glob(["*.html"]),
+    package = "templates",
+    flatten = True,
+)
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        ":templates_data",  # keep
+    ],
+    visibility = [
+        "//hswaw/oodviewer:__pkg__",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/oodviewer/templates",
+)
diff --git a/hswaw/oodviewer/templates/base.html b/hswaw/oodviewer/templates/base.html
new file mode 100644
index 0000000..7574bd9
--- /dev/null
+++ b/hswaw/oodviewer/templates/base.html
@@ -0,0 +1,8 @@
+<html>
+    <head>
+        <title>oodviewer</title>
+    </head>
+    <body>
+{{ template "body" . }}
+    </body>
+</html>
diff --git a/hswaw/oodviewer/templates/term.html b/hswaw/oodviewer/templates/term.html
new file mode 100644
index 0000000..990de29
--- /dev/null
+++ b/hswaw/oodviewer/templates/term.html
@@ -0,0 +1,8 @@
+{{ define "body" }}
+<h1>Entries for {{ .Name }}</h1>
+<ul>
+{{ range .Entries }}
+    <li>{{ .Entry }} <i>(added by {{ .Author }} on {{ .Added }})</i></li>
+{{ end }}
+</ul>
+{{ end }}
diff --git a/hswaw/oodviewer/templates/terms.html b/hswaw/oodviewer/templates/terms.html
new file mode 100644
index 0000000..c628e66
--- /dev/null
+++ b/hswaw/oodviewer/templates/terms.html
@@ -0,0 +1,8 @@
+{{ define "body" }}
+<h1>Available terms:</h1>
+<ul>
+{{ range .Terms }}
+    <li><a href="{{ .URL }}">{{ .Name }}</a> ({{ .Count }} entries)</li>
+{{ end }}
+</ul>
+{{ end }}
diff --git a/hswaw/oodviewer/views.go b/hswaw/oodviewer/views.go
new file mode 100644
index 0000000..b19188a
--- /dev/null
+++ b/hswaw/oodviewer/views.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"math/rand"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/golang/glog"
+
+	"code.hackerspace.pl/hscloud/hswaw/oodviewer/templates"
+)
+
+var (
+	tplBase  = template.Must(template.New("base").Parse(string(templates.Data["base.html"])))
+	tplTerm  = template.Must(template.Must(tplBase.Clone()).Parse(string(templates.Data["term.html"])))
+	tplTerms = template.Must(template.Must(tplBase.Clone()).Parse(string(templates.Data["terms.html"])))
+)
+
+// handleTermsJson returns a JSON list of all terms.
+func (a *app) handleTermsJson(w http.ResponseWriter, r *http.Request) {
+	terms, err := a.getTerms(r.Context())
+	if err != nil {
+		glog.Errorf("getTerms: %v", err)
+		w.WriteHeader(500)
+		fmt.Fprintf(w, "internal error")
+		return
+	}
+	// Target API from old oodviewer, even if it's terrible.
+	var res [][]interface{}
+	for _, term := range terms {
+		res = append(res, []interface{}{
+			term.Name, term.Entries,
+		})
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(res)
+}
+
+// handleTerms renders a HTML page containing all terms.
+func (a *app) handleTerms(w http.ResponseWriter, r *http.Request) {
+	terms, err := a.getTerms(r.Context())
+	if err != nil {
+		glog.Errorf("getTerms: %v", err)
+		w.WriteHeader(500)
+		fmt.Fprintf(w, "internal error")
+		return
+	}
+
+	termsData := make([]struct {
+		URL   string
+		Name  string
+		Count uint64
+	}, len(terms))
+
+	for i, term := range terms {
+		termsData[i].URL = "/term/" + url.QueryEscape(term.Name)
+		termsData[i].Name = term.Name
+		termsData[i].Count = term.Entries
+	}
+
+	tplTerms.Execute(w, map[string]interface{}{
+		"Terms": termsData,
+	})
+}
+
+// handleTermJson returns a JSON list of all entries contained within a term.
+func (a *app) handleTermJson(w http.ResponseWriter, r *http.Request) {
+	parts := strings.Split(r.URL.Path, "/")
+	name := parts[len(parts)-1]
+
+	entries, err := a.getEntries(r.Context(), name)
+	if err != nil {
+		glog.Errorf("getEntries: %v", err)
+		w.WriteHeader(500)
+		fmt.Fprintf(w, "internal error")
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(entries)
+}
+
+// handleRandomTermJson returns a JSON serialized randomly chosen entry from a
+// given term.
+func (a *app) handleRandomTermJson(w http.ResponseWriter, r *http.Request) {
+	parts := strings.Split(r.URL.Path, "/")
+	name := parts[len(parts)-1]
+
+	entries, err := a.getEntries(r.Context(), name)
+	if err != nil {
+		glog.Errorf("getEntries: %v", err)
+		w.WriteHeader(500)
+		fmt.Fprintf(w, "internal error")
+		return
+	}
+	if len(entries) < 1 {
+		w.WriteHeader(404)
+		fmt.Fprintf(w, "no such entry")
+		return
+	}
+	entry := entries[rand.Intn(len(entries))]
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(entry)
+}
+
+// handleTerm renders an HTML page of all entries contained within a term.
+func (a *app) handleTerm(w http.ResponseWriter, r *http.Request) {
+	parts := strings.Split(r.URL.Path, "/")
+	name := parts[len(parts)-1]
+
+	entries, err := a.getEntries(r.Context(), name)
+	if err != nil {
+		glog.Errorf("getEntries: %v", err)
+		w.WriteHeader(500)
+		fmt.Fprintf(w, "internal error")
+		return
+	}
+
+	entriesData := make([]struct {
+		Entry  string
+		Author string
+		Added  string
+	}, len(entries))
+	for i, entry := range entries {
+		entriesData[i].Entry = entry.Entry
+		entriesData[i].Author = entry.Author
+		entriesData[i].Added = time.Unix(entry.Added, 0).String()
+	}
+
+	tplTerm.Execute(w, map[string]interface{}{
+		"Name":    name,
+		"Entries": entriesData,
+	})
+}
diff --git a/ops/machines.nix b/ops/machines.nix
index c341ec4..0e63228 100644
--- a/ops/machines.nix
+++ b/ops/machines.nix
@@ -6,14 +6,15 @@
 # Currently building/deployment is still done in a half-assed way:
 #
 #    machine=edge01.waw.bgp.wtf
-#    nix-build -A 'ops.machines."'$machine'"'.toplevel
+#    d=$(nix-build -A 'ops.machines."'$machine'"'.toplevel)
 #
-# This spits out a derivation path that correponds to the built config of that
-# machine. To deploy it:
+# To then deploy derivation $d on $machine:
 #
-#    d=/nix/store/nkdfoobarbazl0ybhazkmeyaylmaoqcr-nixos-system-edge01-20.09pre-git
 #    nix-copy-closure --to root@$machine $d
-#    ssh root@$machine $d/bin/switch-to-configuration
+#    ssh root@$machine $d/bin/switch-to-configuration dry-activate
+#    ssh root@$machine $d/bin/switch-to-configuration test
+#    ssh root@$machine nix-env -p /nix/var/nix/profiles/system --set $d
+#    ssh root@$machine $d/bin/switch-to-configuration boot
 #
 # TODO(q3k): merge this with //cluster/clustercfg - this should be unified!
 
@@ -35,7 +36,7 @@
     ];
   });
 
-  mkMachine = paths: pkgs.nixos ({ config, pkgs, ... }: {
+  mkMachine = pkgs: paths: pkgs.nixos ({ config, pkgs, ... }: {
     imports = paths;
   });
 
@@ -46,7 +47,16 @@
   "dcr01s22.hswaw.net" = mkClusterMachine "dcr01s22";
   "dcr01s24.hswaw.net" = mkClusterMachine "dcr01s24";
 
-  "edge01.waw.bgp.wtf" = mkMachine [
+  # edge01 still lives on an old nixpkgs checkout.
+  # TODO(b/3): unpin and deploy.
+  "edge01.waw.bgp.wtf" = mkMachine (
+    import (pkgs.fetchFromGitHub {
+      owner = "nixos";
+      repo = "nixpkgs-channels";
+      rev = "c59ea8b8a0e7f927e7291c14ea6cd1bd3a16ff38";
+      sha256 = "1ak7jqx94fjhc68xh1lh35kh3w3ndbadprrb762qgvcfb8351x8v";
+    }) {}
+  ) [
     ../bgpwtf/machines/edge01.waw.bgp.wtf.nix
     ../bgpwtf/machines/edge01.waw.bgp.wtf-hardware.nix
   ];
diff --git a/personal/implr/vpn/vpn.jsonnet b/personal/implr/vpn/vpn.jsonnet
index 3f39231..c467c5e 100644
--- a/personal/implr/vpn/vpn.jsonnet
+++ b/personal/implr/vpn/vpn.jsonnet
@@ -19,7 +19,27 @@
                     keepalive 10 60
                     persist-tun
                     persist-key
-                    compress lz4
+                    cipher AES-256-CBC
+                    dh none
+                    ca /mnt/pki/ca.crt
+                    cert /mnt/pki/tls.crt
+                    key /mnt/pki/tls.key
+                |||
+            }
+        },
+        curssys: vpn.Server("openvpn-implr-curssys", 11224, top.tls) {
+            cfg+: {
+                namespace: "implr-vpn",
+                configFile: |||
+                    dev tun
+                    tmp-dir /dev/shm/
+                    proto udp
+                    port 11224
+                    topology subnet
+                    server 172.20.1.0 255.255.255.0
+                    keepalive 10 60
+                    persist-tun
+                    persist-key
                     cipher AES-256-CBC
                     dh none
                     ca /mnt/pki/ca.crt
@@ -33,5 +53,7 @@
         kektop: vpn.Client("kektop", top.servers.praisethesun),
         admin1: vpn.Client("admin1", top.servers.praisethesun),
         desk1: vpn.Client("desk1", top.servers.praisethesun),
+        desk2: vpn.Client("desk2", top.servers.curssys),
+        thonk: vpn.Client("thonk", top.servers.curssys),
     }
 }
diff --git a/personal/q3k/BUILD b/personal/q3k/BUILD
index 8317e7c..e69de29 100644
--- a/personal/q3k/BUILD
+++ b/personal/q3k/BUILD
@@ -1,9 +0,0 @@
-load("@pydeps//:requirements.bzl", "requirement")
-
-py_binary(
-    name = "django-admin",
-    srcs = ["django-admin.py"],
-    deps = [
-        requirement("django"),
-    ]
-)
diff --git a/personal/q3k/django-admin.py b/personal/q3k/django-admin.py
deleted file mode 100644
index 8648efa..0000000
--- a/personal/q3k/django-admin.py
+++ /dev/null
@@ -1,5 +0,0 @@
-#!python
-from django.core import management
-
-if __name__ == "__main__":
-    management.execute_from_command_line()
diff --git a/personal/q3k/djtest/BUILD b/personal/q3k/djtest/BUILD
deleted file mode 100644
index 8e82b25..0000000
--- a/personal/q3k/djtest/BUILD
+++ /dev/null
@@ -1,30 +0,0 @@
-load("@pydeps//:requirements.bzl", "requirement")
-
-py_library(
-    name = "app",
-    srcs = glob(["djtest/**/*.py"]),
-    deps = [
-        requirement("django"),
-        requirement("pytz"),
-        requirement("sqlparse"),
-    ],
-)
-
-py_binary(
-    name = "manage",
-    srcs = ["manage.py"],
-    deps = [
-       ":app",
-    ],
-)
-
-py_binary(
-    name = "uwsgi-start",
-    srcs = ["uwsgi-start.py"],
-    deps = [
-       ":app",
-       "@bazel_tools//tools/python/runfiles",
-        requirement("uwsgi"),
-        requirement("pyelftools"),
-    ],
-)
diff --git a/personal/q3k/djtest/djtest/__init__.py b/personal/q3k/djtest/djtest/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/personal/q3k/djtest/djtest/__init__.py
+++ /dev/null
diff --git a/personal/q3k/djtest/djtest/settings.py b/personal/q3k/djtest/djtest/settings.py
deleted file mode 100644
index 1942799..0000000
--- a/personal/q3k/djtest/djtest/settings.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-Django settings for djtest project.
-
-Generated by 'django-admin startproject' using Django 2.2.3.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/2.2/topics/settings/
-
-For the full list of settings and their values, see
-https://docs.djangoproject.com/en/2.2/ref/settings/
-"""
-
-import os
-
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-
-# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
-
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'av&&kc(mhuhms+s+av-lz+3d3a*)%!f1$7u0^)91t3)()ix*j@'
-
-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
-
-ALLOWED_HOSTS = []
-
-
-# Application definition
-
-INSTALLED_APPS = [
-    'django.contrib.admin',
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-]
-
-MIDDLEWARE = [
-    'django.middleware.security.SecurityMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.common.CommonMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    'django.middleware.clickjacking.XFrameOptionsMiddleware',
-]
-
-ROOT_URLCONF = 'djtest.urls'
-
-TEMPLATES = [
-    {
-        'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [],
-        'APP_DIRS': True,
-        'OPTIONS': {
-            'context_processors': [
-                'django.template.context_processors.debug',
-                'django.template.context_processors.request',
-                'django.contrib.auth.context_processors.auth',
-                'django.contrib.messages.context_processors.messages',
-            ],
-        },
-    },
-]
-
-WSGI_APPLICATION = 'djtest.wsgi.application'
-
-
-# Database
-# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
-
-DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
-    }
-}
-
-
-# Password validation
-# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
-
-AUTH_PASSWORD_VALIDATORS = [
-    {
-        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
-    },
-]
-
-
-# Internationalization
-# https://docs.djangoproject.com/en/2.2/topics/i18n/
-
-LANGUAGE_CODE = 'en-us'
-
-TIME_ZONE = 'UTC'
-
-USE_I18N = True
-
-USE_L10N = True
-
-USE_TZ = True
-
-
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/2.2/howto/static-files/
-
-STATIC_URL = '/static/'
diff --git a/personal/q3k/djtest/djtest/urls.py b/personal/q3k/djtest/djtest/urls.py
deleted file mode 100644
index 633f699..0000000
--- a/personal/q3k/djtest/djtest/urls.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""djtest URL Configuration
-
-The `urlpatterns` list routes URLs to views. For more information please see:
-    https://docs.djangoproject.com/en/2.2/topics/http/urls/
-Examples:
-Function views
-    1. Add an import:  from my_app import views
-    2. Add a URL to urlpatterns:  path('', views.home, name='home')
-Class-based views
-    1. Add an import:  from other_app.views import Home
-    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
-Including another URLconf
-    1. Import the include() function: from django.urls import include, path
-    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
-"""
-from django.contrib import admin
-from django.urls import path
-
-urlpatterns = [
-    path('admin/', admin.site.urls),
-]
diff --git a/personal/q3k/djtest/djtest/wsgi.py b/personal/q3k/djtest/djtest/wsgi.py
deleted file mode 100644
index ba3abe3..0000000
--- a/personal/q3k/djtest/djtest/wsgi.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-WSGI config for djtest project.
-
-It exposes the WSGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
-"""
-
-import os
-
-from django.core.wsgi import get_wsgi_application
-
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djtest.settings')
-
-application = get_wsgi_application()
diff --git a/personal/q3k/djtest/manage.py b/personal/q3k/djtest/manage.py
deleted file mode 100644
index e7ff590..0000000
--- a/personal/q3k/djtest/manage.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python
-"""Django's command-line utility for administrative tasks."""
-import os
-import sys
-
-
-def main():
-    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djtest.settings')
-    try:
-        from django.core.management import execute_from_command_line
-    except ImportError as exc:
-        raise ImportError(
-            "Couldn't import Django. Are you sure it's installed and "
-            "available on your PYTHONPATH environment variable? Did you "
-            "forget to activate a virtual environment?"
-        ) from exc
-    execute_from_command_line(sys.argv)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/personal/q3k/djtest/uwsgi-start.py b/personal/q3k/djtest/uwsgi-start.py
deleted file mode 100644
index a5c325e..0000000
--- a/personal/q3k/djtest/uwsgi-start.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import configparser
-import os
-import subprocess
-import tempfile
-
-import bazel_tools
-import bazel_tools.tools.python
-
-from elftools.elf.elffile import ELFFile
-from elftools.elf.segments import InterpSegment
-
-from bazel_tools.tools.python.runfiles import runfiles
-r = runfiles.Create()
-
-uwsgi = r.Rlocation("pydeps_pypi__uWSGI_2_0_18/uWSGI-2.0.18.data/scripts/uwsgi")
-settings = r.Rlocation("hscloud/personal/q3k/djtest/djtest/settings.py")
-
-# uwsgi from runfiles is non-chmodded, we have to run it through its interpreter
-ld = None
-with open(uwsgi, 'rb') as f:
-    elffile = ELFFile(f)
-    for segment in elffile.iter_segments():
-        if isinstance(segment, InterpSegment):
-            ld = segment.get_interp_name()
-if ld is None:
-    raise Exception("could not find interpreter/ld.so path in uwsgi - failing")
-
-apppath = os.path.dirname(settings)
-sitepath = os.path.dirname(apppath)
-
-pythonpath = os.environ['PYTHONPATH']
-
-# Make UWSGI ini config file
-cfgf = tempfile.NamedTemporaryFile(mode='w', delete=False)
-
-config = configparser.ConfigParser()
-config['uwsgi'] = {}
-config['uwsgi']['master'] = '1'
-config['uwsgi']['chdir'] = sitepath
-config['uwsgi']['module'] = 'djtest.wsgi'
-config['uwsgi']['env'] = 'DJANGO_SETTINGS_MODULE=djtest.settings'
-config['uwsgi']['http'] = '127.0.0.1:8080'
-config['uwsgi']['pythonpath'] = pythonpath
-
-config.write(cfgf)
-cfgf.close()
-
-args = [
-    ld,
-    uwsgi,
-    '--ini', cfgf.name,
-]
-
-subprocess.call(args)
-
-os.unlink(cfgf.name)
diff --git a/personal/q3k/mirko.jsonnet b/personal/q3k/mirko.jsonnet
new file mode 100644
index 0000000..bfeccff
--- /dev/null
+++ b/personal/q3k/mirko.jsonnet
@@ -0,0 +1,49 @@
+local mirko = import "../../kube/mirko.libsonnet";
+
+{
+    local top = self,
+    shipstuck:: {
+        cfg:: {
+            image: "registry.k0.hswaw.net/q3k/shipstuck:315532800-0939d664a3eac4c0c67b447265c67bbcda9939d4",
+            domain: error "domain must be set",
+        },
+        component(cfg, env): mirko.Component(env, "shipstuck") {
+            local shipstuck = self,
+            cfg+: {
+                image: cfg.image,
+                container: shipstuck.GoContainer("main", "/personal/q3k/shipstuck") {
+                    command+: [
+                        "-public_address", "0.0.0.0:8080",
+                    ],
+                },
+                ports+: {
+                    publicHTTP: {
+                        public: {
+                            port: 8080,
+                            dns: cfg.domain,
+                        },
+                    },
+                },
+            },
+        },
+    },
+
+    env(name):: mirko.Environment(name) {
+        local env = self,
+        local cfg = self.cfg,
+        cfg+: {
+            shipstuck: top.shipstuck.cfg,
+        },
+        components: {
+            shipstuck: top.shipstuck.component(cfg.shipstuck, env),
+        },
+    },
+
+    prod: top.env("personal-q3k") {
+        cfg+: {
+            shipstuck+: {
+                domain: "shipstuck.q3k.org",
+            },
+        },
+    },
+}
diff --git a/personal/q3k/shipstuck/BUILD.bazel b/personal/q3k/shipstuck/BUILD.bazel
new file mode 100644
index 0000000..08e77f6
--- /dev/null
+++ b/personal/q3k/shipstuck/BUILD.bazel
@@ -0,0 +1,47 @@
+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/personal/q3k/shipstuck",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "//personal/q3k/shipstuck/proto:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_grpc_ecosystem_grpc_gateway//runtime:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "shipstuck",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":shipstuck",
+    ],
+    directory = "/personal/q3k/",
+)
+
+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 = "q3k/shipstuck",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
+
diff --git a/personal/q3k/shipstuck/main.go b/personal/q3k/shipstuck/main.go
new file mode 100644
index 0000000..bacbc5d
--- /dev/null
+++ b/personal/q3k/shipstuck/main.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"net/http"
+	"sync"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"github.com/grpc-ecosystem/grpc-gateway/runtime"
+
+	pb "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto"
+)
+
+type vessel struct {
+	Speed float64 `json:"ss"`
+}
+
+// get retrieves the current status of the ship - returns true if stack, false
+// otherwise.
+func get(ctx context.Context) (shipState, error) {
+	// 2021/03/29/ 17:23 UTC+2 it's been freed!
+	return shipStateFreed, nil
+}
+
+type shipState string
+
+const (
+	shipStateUnknown shipState = "UNKNOWN"
+	shipStateStuck   shipState = "STUCK"
+	shipStateFreed   shipState = "FREED"
+	shipStateTowed   shipState = "TOWED"
+)
+
+type service struct {
+	lastStateMu   sync.RWMutex
+	lastState     shipState
+	lastStateTime time.Time
+}
+
+func (s *service) worker(ctx context.Context) {
+	update := func() {
+		state := shipStateUnknown
+		// shitty back off, good enough.
+		retries := 10
+		for {
+			stuck, err := get(ctx)
+			if err != nil {
+				glog.Warningf("get: %v", err)
+				if retries > 0 {
+					time.Sleep(60 * time.Second)
+					retries -= 1
+				} else {
+					glog.Errorf("giving up on get")
+					break
+				}
+			} else {
+				state = stuck
+				break
+			}
+		}
+
+		glog.Infof("New state: %v", state)
+		s.lastStateMu.Lock()
+		s.lastState = state
+		s.lastStateTime = time.Now()
+		s.lastStateMu.Unlock()
+	}
+
+	update()
+	ticker := time.NewTicker(15 * 60 * time.Second)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			update()
+		}
+	}
+}
+
+func timeMust(t time.Time, err error) time.Time {
+	if err != nil {
+		panic(err)
+	}
+	return t
+}
+
+var (
+	timeStuck = timeMust(time.Parse(
+		"At 15:04 Eastern European Time (-0700) on 2 January 2006",
+		"At 07:40 Eastern European Time (+0200) on 23 March 2021",
+	))
+)
+
+func (s *service) Status(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) {
+	s.lastStateMu.RLock()
+	state := s.lastState
+	lastChecked := s.lastStateTime
+	s.lastStateMu.RUnlock()
+
+	res := &pb.StatusResponse{
+		LastChecked: lastChecked.UnixNano(),
+	}
+	switch state {
+	case shipStateUnknown:
+		res.Current = pb.StatusResponse_STUCKNESS_UNKNOWN
+	case shipStateStuck:
+		res.Current = pb.StatusResponse_STUCKNESS_STUCK
+		res.Elapsed = time.Since(timeStuck).Nanoseconds()
+	case shipStateFreed:
+		res.Current = pb.StatusResponse_STUCKNESS_FREE
+	case shipStateTowed:
+		res.Current = pb.StatusResponse_STUCKNESS_TOWED
+		res.Elapsed = time.Since(timeStuck).Nanoseconds()
+	}
+
+	return res, nil
+}
+
+var (
+	flagPublicAddress string
+)
+
+func main() {
+	flag.StringVar(&flagPublicAddress, "public_address", "127.0.0.1:8080", "Public HTTP/JSON listen address")
+	flag.Parse()
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	s := &service{}
+	pb.RegisterShipStuckServer(m.GRPC(), s)
+
+	publicMux := runtime.NewServeMux()
+	publicSrv := http.Server{
+		Addr:    flagPublicAddress,
+		Handler: publicMux,
+	}
+	go func() {
+		glog.Infof("REST listening on %s", flagPublicAddress)
+		if err := publicSrv.ListenAndServe(); err != nil {
+			glog.Exitf("public ListenAndServe: %v", err)
+		}
+	}()
+	if err := pb.RegisterShipStuckHandlerServer(m.Context(), publicMux, s); err != nil {
+		glog.Exitf("RegisterShipStuckHandlerSerever: %v", err)
+	}
+
+	go s.worker(m.Context())
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	<-m.Done()
+}
diff --git a/personal/q3k/shipstuck/proto/BUILD.bazel b/personal/q3k/shipstuck/proto/BUILD.bazel
new file mode 100644
index 0000000..c1fba40
--- /dev/null
+++ b/personal/q3k/shipstuck/proto/BUILD.bazel
@@ -0,0 +1,31 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+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 = ["shipstuck.proto"],
+    visibility = ["//visibility:public"],
+    deps = ["@go_googleapis//google/api:annotations_proto"],
+)
+
+go_proto_library(
+    name = "proto_go_proto",
+    compilers = [
+        "@com_github_grpc_ecosystem_grpc_gateway//protoc-gen-grpc-gateway:go_gen_grpc_gateway",  # keep
+        "@io_bazel_rules_go//proto:go_grpc",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto",
+    proto = ":proto_proto",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@go_googleapis//google/api:annotations_go_proto",
+    ],
+)
+
+go_library(
+    name = "go_default_library",
+    embed = [":proto_go_proto"],
+    importpath = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/personal/q3k/shipstuck/proto/shipstuck.proto b/personal/q3k/shipstuck/proto/shipstuck.proto
new file mode 100644
index 0000000..a76d199
--- /dev/null
+++ b/personal/q3k/shipstuck/proto/shipstuck.proto
@@ -0,0 +1,31 @@
+syntax = "proto3";
+package proto;
+option go_package = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto";
+
+import "google/api/annotations.proto";
+
+service ShipStuck {
+    rpc Status(StatusRequest) returns (StatusResponse) {
+        option (google.api.http) = {
+          get: "/v1/shipstuck/status"
+        };
+    };
+}
+
+message StatusRequest {
+}
+
+message StatusResponse {
+    // Timestamp (nanos from epoch) of last check.
+    int64 last_checked = 1;
+    enum Stuckness {
+        STUCKNESS_INVALID = 0;
+        STUCKNESS_STUCK = 1;
+        STUCKNESS_FREE = 2;
+        STUCKNESS_UNKNOWN = 3;
+        STUCKNESS_TOWED = 4;
+    };
+    Stuckness current = 2;
+    // If STUCK or TOWED, how many nanoseconds have elapsed since the whoopsie?
+    int64 elapsed = 3;
+}
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..3e0bdc4
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,37 @@
+# Shell for being able to use bazel on NixOS (or on any operating system with Nix installed).
+
+let
+
+  hscloud = import ./default.nix {};
+
+in with hscloud.config.pkgs; let
+
+  wrapper = pkgs.writeScript "wrapper.sh"
+  ''
+    source /etc/profile
+    source ${toString ./.}/env.sh
+    ${toString ./.}/tools/install.sh
+
+    exec bash "$@"
+  '';
+
+in (pkgs.buildFHSUserEnv {
+  name = "hscloud-build";
+  targetPkgs = pkgs: with pkgs; [
+    bazel
+    postgresql
+    python38
+    openjdk11
+    openldap.dev cyrus_sasl.dev # for python-ldap
+    wkhtmltopdf
+    gcc binutils
+  ];
+  multiPkgs = pkgs: [
+    (pkgs.runCommand "protocols" {}
+      ''
+      mkdir -p $out/etc
+      ln -s ${pkgs.iana-etc}/etc/protocols $out/etc/protocols
+      '')
+  ];
+  runScript = wrapper;
+}).env
diff --git a/third_party/factorio/factorio.bzl b/third_party/factorio/factorio.bzl
index 6686392..67ae5c1 100644
--- a/third_party/factorio/factorio.bzl
+++ b/third_party/factorio/factorio.bzl
@@ -16,14 +16,6 @@
 
 # version -> sha256 of server tarball
 _versions = {
-    "0.16.51": "6cb09f5ac87f16f8d5b43cef26c0ae26cc46a57a0382e253dfda032dc5bb367f",
-    "0.17.41": "bf2d16b23c3bbd97e41889d3e27670b6d958fa3d50f0befb41d234f735e8e6d1",
-    "0.17.52": "24458a4e16875b0b63677b7e7a068ce2e5b298c110381d17c6f596fd1406db0e",
-    "0.17.79": "9ace12fa986df028dc1851bf4de2cb038044d743e98823bc1c48ba21aa4d23df",
-    "0.18.12": "e0c6a46d66cfc02cba294a5fd34265e7e7a5168b8c8a7b16ad8dbac31470ed33",
-    "0.18.17": "42adce9fddde393023afb0aae19dd030a32ca0810191c0e7b9b7c55556e9bbce",
-    "0.18.22": "d90e349b61182c1e48bd34797faedc2f9b5b4e349d218ef3d987ae9d90762f7f",
-    "0.18.40": "696fe660fea945f38d12d49cf0b4737522d061fab5b3afd59467c4b2e375711a",
     "1.0.0": "81d9e1aa94435aeec4131c8869fa6e9331726bea1ea31db750b65ba42dbd1464",
 }
 
diff --git a/third_party/go/repositories.bzl b/third_party/go/repositories.bzl
index 2a9c249..d4355a6 100644
--- a/third_party/go/repositories.bzl
+++ b/third_party/go/repositories.bzl
@@ -1790,3 +1790,39 @@
         version = "v0.3.0",
         build_extra_args = ["-exclude=src"],
     )
+    go_repository(
+        name = "com_github_minio_minio_go_v7",
+        importpath = "github.com/minio/minio-go/v7",
+        sum = "h1:1oUKe4EOPUEhw2qnPQaPsJ0lmVTYLFu03SiItauXs94=",
+        version = "v7.0.10",
+    )
+    go_repository(
+        name = "in_gopkg_ini_v1",
+        importpath = "gopkg.in/ini.v1",
+        sum = "h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=",
+        version = "v1.62.0",
+    )
+    go_repository(
+        name = "com_github_minio_md5_simd",
+        importpath = "github.com/minio/md5-simd",
+        sum = "h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=",
+        version = "v1.1.2",
+    )
+    go_repository(
+        name = "com_github_minio_sha256_simd",
+        importpath = "github.com/minio/sha256-simd",
+        sum = "h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=",
+        version = "v1.0.0",
+    )
+    go_repository(
+        name = "com_github_klauspost_cpuid_v2",
+        importpath = "github.com/klauspost/cpuid/v2",
+        sum = "h1:qnfhwbFriwDIX51QncuNU5mEMf+6KE3t7O8V2KQl3Dg=",
+        version = "v2.0.5",
+    )
+    go_repository(
+        name = "com_github_rs_xid",
+        importpath = "github.com/rs/xid",
+        sum = "h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=",
+        version = "v1.2.1",
+    )
diff --git a/third_party/java/maven_install.json b/third_party/java/maven_install.json
index cc4555c..32e9fe8 100644
--- a/third_party/java/maven_install.json
+++ b/third_party/java/maven_install.json
@@ -1162,7 +1162,7 @@
             },
             {
                 "coord": "org.spigotmc:spigot-api:1.15.2-R0.1-SNAPSHOT",
-                "file": "v1/https/hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200509.094510-108.jar",
+                "file": "v1/https/hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200624.001023-124.jar",
                 "directDependencies": [
                     "commons-lang:commons-lang:2.6",
                     "org.yaml:snakeyaml:1.25",
@@ -1183,13 +1183,13 @@
                     "com.google.guava:guava:28.2-android",
                     "org.checkerframework:checker-compat-qual:2.5.5"
                 ],
-                "url": "https://hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200509.094510-108.jar",
+                "url": "https://hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200624.001023-124.jar",
                 "mirror_urls": [
-                    "https://hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200509.094510-108.jar",
+                    "https://hub.spigotmc.org/nexus/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200624.001023-124.jar",
                     "https://oss.sonatype.org/content/repositories/snapshots/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200509.094510-108.jar",
                     "https://repo1.maven.org/maven2/org/spigotmc/spigot-api/1.15.2-R0.1-SNAPSHOT/spigot-api-1.15.2-R0.1-20200509.094510-108.jar"
                 ],
-                "sha256": "243c81927517f29ff2bf51303beba6470cb92ae15f82fadd0d8ba86c5715e5f8"
+                "sha256": "4c41b74be867d62581e7f8c3ac22b06908b3d8a481d00aa87def7c967e829b71"
             },
             {
                 "coord": "org.yaml:snakeyaml:1.25",
@@ -1206,6 +1206,6 @@
             }
         ],
         "version": "0.1.0",
-        "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": 392476608
+        "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": -1186947498
     }
 }
diff --git a/third_party/nix/BUILD b/third_party/nix/BUILD
deleted file mode 100644
index 12ae0fa..0000000
--- a/third_party/nix/BUILD
+++ /dev/null
@@ -1,29 +0,0 @@
-load("@rules_python//python:defs.bzl", "py_runtime_pair")
-
-# Python toolchain definition that uses //third_party/nix:python.nix (via
-# external repository).
-
-py_runtime(
-    name = "py3_runtime",
-    interpreter = "@hscloud_nix_python3//:python3",
-    python_version = "PY3",
-)
-
-py_runtime(
-    name = "py2_runtime",
-    interpreter = "@hscloud_nix_python2//:python2",
-    python_version = "PY2",
-)
-
-
-py_runtime_pair(
-    name = "py_runtime_pair",
-    py2_runtime = ":py2_runtime",
-    py3_runtime = ":py3_runtime",
-)
-
-toolchain(
-    name = "py_toolchain",
-    toolchain = ":py_runtime_pair",
-    toolchain_type = "@rules_python//python:toolchain_type",
-)
diff --git a/third_party/nix/python.nix b/third_party/nix/python.nix
deleted file mode 100644
index 563127e..0000000
--- a/third_party/nix/python.nix
+++ /dev/null
@@ -1,46 +0,0 @@
-# This is a Python interpreter wrapper that's passed to pip3_import under
-# NixOS.
-# It allows us to build some pip wheels under NixOS that require special
-# system libraries. This is quite hacky, it would be much better if we could
-# somehow tell pip3_import that a given package needs to be built within a
-# given environment.
-
-with import <nixpkgs> {};
-
-let
-  # We use mkDerivation instead of writeScript or writeScriptBin as we need a
-  # derivation that both:
-  # - has a directory structure (for rules_nixpkgs to be able to use it)
-  # - has the Python interpreter directly in that structure and not in bin/, as
-  #   rules_python's pip3_import interpreter_path requires a file target, and
-  #   will not take an alias. Meanwhile, rules_nixpkgs only creates a BUILD file
-  #   in the root path of the external repository (which is populated with a
-  #   symlink tree from the nix derivation), so we can onlly directly reference
-  #   file in the root of a Nix derivation.
-  generic = package: binary:  stdenv.mkDerivation {
-    name = "${binary}-wrapper";
-    version = "1.0";
-    src = ./.;
-    unpackPhase = "";
-    buildPhase = ''
-      mkdir -p $out
-      cat > $out/${binary} <<EOF
-#!/bin/bash
-
-# pyscopg wants libpq, and uses pg_config to find paths. Inject pg_config into
-# the Python interpreter's path.
-export PATH="${pkgs.postgresql}/bin:\$PATH"
-
-exec ${package}/bin/${binary} "\$@"
-EOF
-    '';
-    installPhase = ''
-      chmod +x $out/${binary}
-    '';
-  };
-
-in {
-  # Add cffi for import _cffi_backend in `cryptography` to work.
-  python2 = generic (pkgs.python27.withPackages (ps: with ps; [ cffi ])) "python2";
-  python3 = generic (pkgs.python37.withPackages (ps: with ps; [ cffi ])) "python3";
-}
diff --git a/third_party/nix/repository_rules.bzl b/third_party/nix/repository_rules.bzl
deleted file mode 100644
index 35c3d2d..0000000
--- a/third_party/nix/repository_rules.bzl
+++ /dev/null
@@ -1,108 +0,0 @@
-load("@io_tweag_rules_nixpkgs//nixpkgs:repositories.bzl", "rules_nixpkgs_dependencies")
-load("@io_tweag_rules_nixpkgs//nixpkgs:nixpkgs.bzl", "nixpkgs_git_repository", "nixpkgs_package")
-
-def has_nix(ctx):
-    return ctx.which("nix-build") != None
-
-def _hscloud_gen_go_imports_impl(ctx):
-    ctx.file("BUILD", "")
-
-    imports_for_nix = """
-load("@io_tweag_rules_nixpkgs//nixpkgs:toolchains/go.bzl", "nixpkgs_go_configure")
-
-def hscloud_go_register_toolchains():
-    nixpkgs_go_configure(repository = "@nixpkgs")
-"""
-    imports_for_non_nix = """
-load("@io_bazel_rules_go//go:deps.bzl", "go_rules_dependencies", "go_register_toolchains")
-def hscloud_go_register_toolchains():
-    go_register_toolchains()
-"""
-
-    if has_nix(ctx):
-        ctx.file("imports.bzl", imports_for_nix)
-    else:
-        ctx.file("imports.bzl", imports_for_non_nix)
-
-# Generate repository containing either a call to go_register_toolchains() or
-# nixpkgs_go_configure(), depending on nix presence.
-hscloud_gen_go_imports = repository_rule(
-    implementation = _hscloud_gen_go_imports_impl,
-    attrs = dict(),
-)
-
-def _hscloud_gen_pip_imports_impl(ctx):
-    ctx.file("BUILD", "")
-
-    # For Nix, we have to both pass our interpreter to pip3_import, and also
-    # register it as a toolchain.
-    imports_for_nix = """
-load("@rules_python//python:pip.bzl", "pip3_import")
-def hscloud_pip3_import(name, requirements):
-    pip3_import(
-        name = name,
-        requirements = requirements,
-        python_interpreter_target = "@hscloud_nix_python3//:python3",
-    )
-    native.register_toolchains("//third_party/nix:py_toolchain")
-"""
-    imports_for_non_nix = """
-load("@rules_python//python:pip.bzl", "pip3_import")
-def hscloud_pip3_import(name, requirements):
-    pip3_import(
-        name = name,
-        requirements = requirements,
-    )
-"""
-    if has_nix(ctx):
-        ctx.file("imports.bzl", imports_for_nix)
-    else:
-        ctx.file("imports.bzl", imports_for_non_nix)
-
-# Generate repository containing a wrapped pip3_import that either uses the
-# host Python interpreter or one from nixpkgs, depending on nix presence.
-hscloud_gen_pip_imports = repository_rule(
-    implementation = _hscloud_gen_pip_imports_impl,
-    attrs = dict(),
-)
-
-def hscloud_setup_nix(revision, sha256):
-    rules_nixpkgs_dependencies()
-    nixpkgs_git_repository(
-        name = "nixpkgs",
-        revision = "1179840f9a88b8a548f4b11d1a03aa25a790c379",
-        sha256 = "8b64041bfb9760de9e797c0a985a4830880c21732489f397e217d877edd9a990",
-    )
-
-    # Load python from nixpkgs. Python is a large source of non-hermiticity,
-    # and loading it from nix vastly hermeticizes the build - well, at least to
-    # also be dependent on this Nix store state. That's still better than just
-    # grabbing whatever random system Python a user might have.
-    nixpkgs_package(
-        name = "hscloud_nix_python2",
-        repositories = { "nixpkgs": "@nixpkgs//:default.nix" },
-        nix_file = "//third_party/nix:python.nix",
-        attribute_path = "python2",
-        build_file_content = """
-package(default_visibility = ["//visibility:public"])
-exports_files(["python2"])
-        """,
-    )
-    nixpkgs_package(
-        name = "hscloud_nix_python3",
-        repositories = { "nixpkgs": "@nixpkgs//:default.nix" },
-        nix_file = "//third_party/nix:python.nix",
-        attribute_path = "python3",
-        build_file_content = """
-package(default_visibility = ["//visibility:public"])
-exports_files(["python3"])
-        """,
-    )
-
-    # Generate a Go toolchain setup workspace rule.
-    hscloud_gen_go_imports(
-        name = "hscloud_go_toolchain",
-    )
-    hscloud_gen_pip_imports(
-        name = "hscloud_pip_imports",
-    )