Merge "kube/postgres: add extra options configuration option"
diff --git a/app/matrix/lib/appservice-telegram.libsonnet b/app/matrix/lib/appservice-telegram.libsonnet
index fd2a9a0..6700fbc 100644
--- a/app/matrix/lib/appservice-telegram.libsonnet
+++ b/app/matrix/lib/appservice-telegram.libsonnet
@@ -113,9 +113,7 @@
                                     registration: { mountPath: "/registration", },
                                     data: { mountPath: "/data" },
                                 },
-                                // Ow, the edge! We need yq.
-                                // See: https://github.com/mikefarah/yq/issues/190#issuecomment-667519015
-                                image: "alpine@sha256:156f59dc1cbe233827642e09ed06e259ef6fa1ca9b2e29d52ae14d5e7b79d7f0",
+                                image: "alpine:3.13",
                                 command: [
                                     "sh", "-c", |||
                                         set -e -x
diff --git a/cluster/admitomatic/BUILD.bazel b/cluster/admitomatic/BUILD.bazel
index 55c7466..32437b2 100644
--- a/cluster/admitomatic/BUILD.bazel
+++ b/cluster/admitomatic/BUILD.bazel
@@ -1,3 +1,4 @@
+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_test")
 
 go_library(
@@ -37,3 +38,28 @@
         "@io_k8s_apimachinery//pkg/runtime:go_default_library",
     ],
 )
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":admitomatic",
+    ],
+    directory = "/cluster/admitomatic/",
+)
+
+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/admitomatic",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/cluster/admitomatic/ingress.go b/cluster/admitomatic/ingress.go
index a1d57a5..6b8a365 100644
--- a/cluster/admitomatic/ingress.go
+++ b/cluster/admitomatic/ingress.go
@@ -210,6 +210,8 @@
 		"proxy-body-size":  true,
 		"ssl-redirect":     true,
 		"backend-protocol": true,
+		// Used by cert-manager
+		"whitelist-source-range": true,
 	}
 	prefix := "nginx.ingress.kubernetes.io/"
 	for k, _ := range ingress.Annotations {
diff --git a/cluster/certs/admitomatic-webhook.cert b/cluster/certs/admitomatic-webhook.cert
new file mode 100644
index 0000000..8cf13e1
--- /dev/null
+++ b/cluster/certs/admitomatic-webhook.cert
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFNzCCBB+gAwIBAgIUXbM3lV2FTPdWgZvW7jFsQIpTK/IwDQYJKoZIhvcNAQEL
+BQAwgYcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
+BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
+CmNsdXN0ZXJjZmcxHzAdBgNVBAMTFmFkbWl0b21hdGljIHdlYmhvb2sgQ0EwHhcN
+MjEwMjA2MTQ0ODAwWhcNMjIwMjA2MTQ0ODAwWjB4MQswCQYDVQQGEwJQTDEUMBIG
+A1UECBMLTWF6b3dpZWNraWUxDzANBgNVBAcTBldhcnNhdzEcMBoGA1UECxMTQWRt
+aXRvbWF0aWMgV2ViaG9vazEkMCIGA1UEAxMbYWRtaXRvbWF0aWMuYWRtaXRvbWF0
+aWMuc3ZjMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAx3pwfFQ4AqBl
+h4OYzp6y43DKNjI0AkwyisIL4IKGJX+UOCOHuC6DfzpGH+GGnO5xKyEu01x7X6wx
+y006OVEuMXR0mghi7imRdVS3jzIA2z49XZUAU2f2q2EJmF9zUrDWB0PGbNjrUPDP
+aE8f/mHUT8imkVryB09TFi6+RXG0lIxqk94PYIm3cG95Ht/5LzYE2AzEVJ8iu7VP
+/9qycaLcUi4wx4fqUyOgUD1Y6KZnlN8zlKB6PZx7PMoeHyt/BKe0QBa0Zp6hNsU8
+o2l9bI4HyRiXELFQddUl6SJdxGrqWX1sGy+xLQW3Qx+LxjUAepMsygzmQ2LoAKSr
+/NRdExGdnUtptAKf6S8MakT8cRZMMK33aFk7Zh18bmkAB4Y60xK0fe6wBczVd4ob
+AWjdqSs8aP8Jc5z37qCNiPiUee5BcGNbL1YalStVuOW74+EezeMhHVQDAj2QsO7z
+U4ZaM+F3K2UdLiHkSd8u/xmoZ59zZU5dcbI0Hj7GTnORqwr2509XwRUkOOatgQpG
+ZAfPMgvEB1B95TPzXSKevevfSVx0Z+oCUm218F2BSnCfoezmNOdD1exvLeaOtddW
+6m49xWOm2ygf1s3+jFXIlstH1mAeFoaIYaFOqM/kLS1LsGinZJGIyqdPUXZX4m9H
+jgSGEovUnc32hwV4qVEnP+K/v6llrt8CAwEAAaOBqDCBpTAOBgNVHQ8BAf8EBAMC
+BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAw
+HQYDVR0OBBYEFNoJpkAMYBkEGmGwT8u8x/LpwEDKMB8GA1UdIwQYMBaAFEWKIVrV
+rPkrF54gbAt/UfCofuzJMCYGA1UdEQQfMB2CG2FkbWl0b21hdGljLmFkbWl0b21h
+dGljLnN2YzANBgkqhkiG9w0BAQsFAAOCAQEAMn/opmBC5KpdnrzvE+kChfda+CSt
+W4z6zyEjGFuyENHLWZuOuSB9abxDQKJ1Jtlojy/GI+gJwyxyAkZ8wQSfmYgHpx/4
+EKqQ0uGL+0TaqFAiRCrIPYH1GPj/hK7/xnndYxMjII4tUUpSpLSllpWmgjhKePQw
+TrEXDLYA2EtvMzZBaw/5HqS/M34P7oWeGj3iFi5iufhsXj5KjmFNEdWduPmVb8Gm
+pMqeco/JALaey739tJeYbN2wwTRgWLNY0BwTmvTQn3X6o44OnJ5KSQvEBzMHJ/ea
+6f9m/Y8daeN6q9TIiT+OLNFWw0hwuZmakje6a8xqBAiwJ3f257VVaUSKkQ==
+-----END CERTIFICATE-----
diff --git a/cluster/certs/ca-admitomatic.crt b/cluster/certs/ca-admitomatic.crt
new file mode 100644
index 0000000..32c901c
--- /dev/null
+++ b/cluster/certs/ca-admitomatic.crt
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID4DCCAsigAwIBAgIUMZTlTIv/Ciky0M0JYork3/5dXEcwDQYJKoZIhvcNAQEL
+BQAwgYcxCzAJBgNVBAYTAlBMMRQwEgYDVQQIEwtNYXpvd2llY2tpZTEPMA0GA1UE
+BxMGV2Fyc2F3MRswGQYDVQQKExJXYXJzYXcgSGFja2Vyc3BhY2UxEzARBgNVBAsT
+CmNsdXN0ZXJjZmcxHzAdBgNVBAMTFmFkbWl0b21hdGljIHdlYmhvb2sgQ0EwHhcN
+MjEwMjA2MTQ0ODAwWhcNMjYwMjA1MTQ0ODAwWjCBhzELMAkGA1UEBhMCUEwxFDAS
+BgNVBAgTC01hem93aWVja2llMQ8wDQYDVQQHEwZXYXJzYXcxGzAZBgNVBAoTEldh
+cnNhdyBIYWNrZXJzcGFjZTETMBEGA1UECxMKY2x1c3RlcmNmZzEfMB0GA1UEAxMW
+YWRtaXRvbWF0aWMgd2ViaG9vayBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
+AQoCggEBALtnMfOLPqXRFdBbq6SDJmWQM8A4AMTkr64+JJZuUdFQYvGzjueNEEIG
+5mCXrEW7IRDFARnohMlE9PPwHZRQcQcE0ph5f/UayfeJ/GuTpOphH9Xw++P6kPvW
+Y/0gZXqFhCexGPXSVIZ+Klt6/0GloWVukyAt5J8LvinXTk8VGW0uvA8VM2GEKA1t
+c2DyF7D7mVuLi8cvKUNZikIO5AzHSSR9mvd2bo8lKtZyswfYTMgHwM4CDywxZZLl
+WqM0tlbJEJgvZXPR03DrAgvEE7N7TSDdgEB+QxUmP9JKWFLeO1zleNR2bxakv4jP
+gOIogJiAXeLIdaSLWqX98o7sSTkz2pUCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEG
+MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEWKIVrVrPkrF54gbAt/UfCofuzJ
+MA0GCSqGSIb3DQEBCwUAA4IBAQAdub0/zEHOoZUaZx5o0IuTeGMzKeL74MxjzpgE
+xpgaGs8EgBC76fre028vrathUd1/Jw9VOatGhOTRSqlscbNW+jSS8h4rtSu76ZHV
+d4XQiaL4olNTiKDOplE/chUMI+oq256nZJFPObGYSXrDnNZ+bcCccKz7KpfhBs81
+Zk949+Y5Mw+FpZOyDAum6JDypYCwv8SFqiI+c4fFZKlYbdbyuF/aGrDVK0NpDSvE
+hMQultRUp738Jel8jkIeY34YGFS4woIOGujR+o7onqjd9+3UgJ4l5ErEJt2k9B5+
+agv5l6cBTJOx3b+Lzu40x+Fusf4CHXDn6JswfjoMMIDhMcPl
+-----END CERTIFICATE-----
diff --git a/cluster/clustercfg/clustercfg.py b/cluster/clustercfg/clustercfg.py
index 410635b..c5f5c6c 100644
--- a/cluster/clustercfg/clustercfg.py
+++ b/cluster/clustercfg/clustercfg.py
@@ -202,6 +202,10 @@
         ca_kubefront = ca.CA(ss, certs_root, 'kubefront', 'kubernetes frontend CA')
         ca_kubefront.make_cert('kubefront-apiserver', ou='Kubernetes Frontend', hosts=['apiserver'])
 
+        ## Make admitomatic (admission controller) certificates.
+        ca_admitomatic = ca.CA(ss, certs_root, 'admitomatic', 'admitomatic webhook CA')
+        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",
diff --git a/cluster/kube/cluster.libsonnet b/cluster/kube/cluster.libsonnet
index c42ee8a..1826b0c 100644
--- a/cluster/kube/cluster.libsonnet
+++ b/cluster/kube/cluster.libsonnet
@@ -120,6 +120,11 @@
                     resources: ["jobs", "cronjobs"],
                     verbs: ["*"],
                 },
+                {
+                    apiGroups: ["networking.k8s.io"],
+                    resources: ["ingresses"],
+                    verbs: ["*"],
+                },
             ],
         },
         // This ClusterRoleBindings allows root access to cluster admins.
diff --git a/cluster/kube/k0-admitomatic.jsonnet b/cluster/kube/k0-admitomatic.jsonnet
new file mode 100644
index 0000000..efff661
--- /dev/null
+++ b/cluster/kube/k0-admitomatic.jsonnet
@@ -0,0 +1,7 @@
+// Only the admitomatic instance in k0.
+
+local k0 = (import "k0.libsonnet").k0;
+
+{
+    admitomatic: k0.admitomatic,
+}
diff --git a/cluster/kube/k0.libsonnet b/cluster/kube/k0.libsonnet
index 44f83d0..8d7d49f 100644
--- a/cluster/kube/k0.libsonnet
+++ b/cluster/kube/k0.libsonnet
@@ -7,6 +7,7 @@
 
 local cluster = import "cluster.libsonnet";
 
+local admitomatic = import "lib/admitomatic.libsonnet";
 local cockroachdb = import "lib/cockroachdb.libsonnet";
 local registry = import "lib/registry.libsonnet";
 local rook = import "lib/rook.libsonnet";
@@ -81,6 +82,7 @@
                 sso: k0.cockroach.waw2.Client("sso"),
                 herpDev: k0.cockroach.waw2.Client("herp-dev"),
                 gitea: k0.cockroach.waw2.Client("gitea"),
+                issues: k0.cockroach.waw2.Client("issues"),
             },
         },
 
@@ -215,6 +217,16 @@
                         displayName: "nextcloud",
                     },
                 },
+                # issues.hackerspace.pl (redmine) attachments bucket
+                issuesWaw3: kube.CephObjectStoreUser("issues") {
+                    metadata+: {
+                        namespace: "ceph-waw3",
+                    },
+                    spec: {
+                        store: "waw-hdd-redundant-3-object",
+                        displayName: "issues",
+                    },
+                },
 
                 # nuke@hackerspace.pl's personal storage.
                 nukePersonalWaw3: kube.CephObjectStoreUser("nuke-personal") {
@@ -297,5 +309,61 @@
             # TODO(implr): restricted policy with CAP_NET_ADMIN and tuntap, but no full root
             policies.AllowNamespaceInsecure("implr-vpn"),
         ],
+
+        # Admission controller that permits non-privileged users to manage
+        # their namespaces without danger of hijacking important URLs.
+        admitomatic: admitomatic.Environment {
+            cfg+: {
+                proto: {
+                    // Domains allowed in given namespaces. If a domain exists
+                    // anywhere, ingresses will only be permitted to be created
+                    // within namespaces in which it appears here. This works
+                    // the same way for wildcards, if a wildcard exists in this
+                    // list it blocks all unauthorized uses of that domain
+                    // elsewhere.
+                    //
+                    // See //cluster/admitomatic for more information.
+                    //
+                    // Or, tl;dr:
+                    //
+                    // If you do a wildcard CNAME onto the k0 ingress, you
+                    // should explicitly state *.your.name.com here.
+                    //
+                    // If you just want to protect your host from being
+                    // hijacked by other cluster users, you should also state
+                    // it here (either as a wildcard, or unary domains).
+                    allow_domain: [
+                        { namespace: "covid-formity", dns: "covid19.hackerspace.pl" },
+                        { namespace: "covid-formity", dns: "covid.hackerspace.pl" },
+                        { namespace: "covid-formity", dns: "www.covid.hackerspace.pl" },
+                        { namespace: "devtools-prod", dns: "hackdoc.hackerspace.pl" },
+                        { namespace: "devtools-prod", dns: "cs.hackerspace.pl" },
+                        { namespace: "engelsystem-prod", dns: "engelsystem.hackerspace.pl" },
+                        { namespace: "gerrit", dns: "gerrit.hackerspace.pl" },
+                        { namespace: "gitea-prod", dns: "gitea.hackerspace.pl" },
+                        { namespace: "hswaw-prod", dns: "*.hackerspace.pl" },
+                        { namespace: "internet", dns: "internet.hackerspace.pl" },
+                        { namespace: "matrix", dns: "matrix.hackerspace.pl" },
+                        { namespace: "onlyoffice-prod", dns: "office.hackerspace.pl" },
+                        { namespace: "redmine", dns: "issues.hackerspace.pl" },
+                        { namespace: "redmine", dns: "b.hackerspace.pl" },
+                        { namespace: "redmine", dns: "b.hswaw.net" },
+                        { namespace: "redmine", dns: "xn--137h.hackerspace.pl" },
+                        { namespace: "redmine", dns: "xn--137h.hswaw.net" },
+                        { namespace: "speedtest", dns: "speedtest.hackerspace.pl" },
+                        { namespace: "sso", dns: "sso.hackerspace.pl" },
+
+                        { namespace: "ceph-waw3", dns: "ceph-waw3.hswaw.net" },
+                        { namespace: "ceph-waw3", dns: "object.ceph-waw3.hswaw.net" },
+                        { namespace: "monitoring-global-k0", dns: "*.hswaw.net" },
+                        { namespace: "registry", dns: "*.hswaw.net" },
+
+                        // q3k's legacy namespace (pre-prodvider)
+                        { namespace: "q3k", dns: "*.q3k.org" },
+                        { namespace: "personal-q3k", dns: "*.q3k.org" },
+                    ],
+                },
+            },
+        },
     },
 }
diff --git a/cluster/kube/lib/admitomatic.libsonnet b/cluster/kube/lib/admitomatic.libsonnet
new file mode 100644
index 0000000..d8e0440
--- /dev/null
+++ b/cluster/kube/lib/admitomatic.libsonnet
@@ -0,0 +1,124 @@
+// Deploys admitomatic, a validating admission webhook. It is used in
+// conjunction with Kubernetes' RBAC to provide a level of multitenancy to the
+// cluster, adding extra restrictions to resources created by non-administrative
+// users.
+//
+// For more information about admitomatic , see //cluster/admitomatic .
+//
+// As with every Kubernetes admission webhook, the Kubernetes control plane
+// (ie. apiserver) needs to be able to dial the deployed admitomatic service.
+// The authentication story for this is unfortunately quite sad and requires
+// the use of a pre-generated one-shot CA and certificate.
+//
+//         .---- self-signed -.
+//         v                  |
+//   Admitomatic CA ----------'  <-- caBundle used by apiserver,
+//         |                         set in ValidatingWebhookConfiguration
+//         v
+//   Admitomatic Cert            <-- admitomatic_tls_cert used by admitomatic
+//
+// This CA needs to be provisioned ahead of time by ourselves. In order to keep
+// things simple (as admitomatic being an admission webhook becomes a core
+// component of the k8s control plane), we generate this CA as plain text
+// secrets, and store them with secretstore in git. This is done via clustercfg.
+
+local kube = import "../../../kube/kube.libsonnet";
+local prototext = import "../../../kube/prototext.libsonnet";
+
+{
+    Environment: {
+        local env = self,
+        local cfg = env.cfg,
+
+        cfg:: {
+            namespace: "admitomatic",
+            image: "registry.k0.hswaw.net/q3k/admitomatic:315532800-6cc2f867951e123909b23955cd7bcbcc3ec24f8a",
+
+            proto: {},
+        },
+
+        namespace: kube.Namespace(cfg.namespace),
+        local ns = self.namespace,
+
+        config: ns.Contain(kube.ConfigMap("admitomatic")) {
+            data: {
+                "config.pb.text": prototext.manifestProtoText(cfg.proto),
+            },
+        },
+
+        secret: ns.Contain(kube.Secret("admitomatic")) {
+            data_: {
+                "webhook.key": importstr "../../secrets/plain/admitomatic-webhook.key",
+                "webhook.crt": importstr "../../certs/admitomatic-webhook.cert",
+            },
+        },
+
+        daemonset: ns.Contain(kube.DaemonSet("admitomatic")) {
+            spec+: {
+                template+: {
+                    spec+: {
+                        containers_: {
+                            default: kube.Container("default") {
+                                image: cfg.image,
+                                args: [
+                                    "/cluster/admitomatic/admitomatic",
+                                    "-admitomatic_config", "/admitomatic/config/config.pb.text",
+                                    "-admitomatic_listen", "0.0.0.0:8443",
+                                    "-admitomatic_tls_cert", "/admitomatic/secret/webhook.crt",
+                                    "-admitomatic_tls_key", "/admitomatic/secret/webhook.key",
+                                    // doesn't serve anything over gRPC.
+                                    "-hspki_disable"
+                                ],
+                                volumeMounts_: {
+                                    config: { mountPath: "/admitomatic/config" },
+                                    secret: { mountPath: "/admitomatic/secret" },
+                                },
+                                ports_: {
+                                    public: { containerPort: 8443 },
+                                },
+                            },
+                        },
+                        volumes_: {
+                            config: kube.ConfigMapVolume(env.config),
+                            secret: kube.SecretVolume(env.secret),
+                        },
+                    },
+                },
+            },
+        },
+
+        svc: ns.Contain(kube.Service("admitomatic")) {
+            target_pod:: env.daemonset.spec.template,
+        },
+
+        webhook: kube.ValidatingWebhookConfiguration("admitomatic") {
+            webhooks_: {
+                "admitomatic.hswaw.net": {
+                    rules: [
+                        {
+                            apiGroups: ["networking.k8s.io"],
+                            apiVersions: ["v1", "v1beta1"],
+                            operations: ["CREATE", "UPDATE"],
+                            resources: ["ingresses"],
+                            scope: "Namespaced",
+                        }
+                    ],
+                    clientConfig: {
+                        service: {
+                            namespace: env.svc.metadata.namespace,
+                            name: env.svc.metadata.name,
+                            port: 8443,
+                            path: "/webhook",
+                        },
+                        caBundle: std.base64(importstr "../../certs/ca-admitomatic.crt"),
+                    },
+                    failurePolicy: "Ignore",
+                    matchPolicy: "Equivalent",
+                    admissionReviewVersions: ["v1", "v1beta1"],
+                    sideEffects: "None",
+                    timeoutSeconds: 5,
+                },
+            },
+        },
+    },
+}
diff --git a/cluster/secrets/cipher/admitomatic-webhook.key b/cluster/secrets/cipher/admitomatic-webhook.key
new file mode 100644
index 0000000..b70e26e
--- /dev/null
+++ b/cluster/secrets/cipher/admitomatic-webhook.key
@@ -0,0 +1,91 @@
+-----BEGIN PGP MESSAGE-----
+
+hQEMAzhuiT4RC8VbAQf9EqRi5t61oOBKn3eue/Utww9QHB6D8tfB1vjm+N1Mzy4a
+VLRzJZBCwIMyUYHbYYx7YviHb2NFyib0KAg1+TTjXMjtcnjHeONIGnak+dbQ3MNO
+HzCx9/n7mlzi+cVbtX6s3eKsdMS0EUqdmi0fFcxwXHioH6CQIMU1orJ1521H9SiR
+eemE7wYxAFVhlXHMbK+v2Xcw1F0Ux+zsgdLcTx6qmwq9nX/KJHtfiuEvcE3AYb8+
+uxcZJYmyl5olb2dUjtvS9I18TH89IusLsQsP8puDcoD3t5e+TtJQbdYWyk0S7WH+
+uPS4QEnaXK3MQ0aBNo1NwKA78n2vHPgBc8gz+EOVkIUBDANcG2tp6fXqvgEIAJEG
+WUT2uCn3ndYF4dprphb7C6SoTpHa6BX2gswk1XuDsOwqdZamyom42E+ECwpHJEM6
+Bm2THv0yXUN7vKkzgQi6sfHs9opKhfig4qbEI4tgL/SQktW3BDuumeEeSyVB7/t8
+TifNPRnDL3UMj4fmDVHyvudgx7L/Vhw1oLSl543+9jYPXO5y0XvJtqDq1y1TEWvc
+oybkLW0Yyx944r1ZJnB+WbLDag/d/NGWsKEBaYmfjRdZcBY9qfQc7dZpJAB9+GZT
+kTNvtXOMiJzP0KQSXLY09XWSyRr3s6o3xoloXXIbuvCITw/9ZzpDTaUnwUEEY2Zk
+5x9WFnkLHyp7RrgW2IWFAgwDodoT8VqRl4UBEAC08L91XGJ51hSNj8dbTPO2rPJ/
+eh5JIDAbqpq/cqZsgcbq9kTqGPSrWTZHvqd4NRKelp1K17wS7wog5cZlb1XreNwY
+yDHnNXICG5vCEB/SLoS4/B4QRxPtfZEYkkh2szmKqAjRMFPt+r/RPiMZTVZNA42c
+MuuAD6TJ1Hune3lBC1a/a3nM/wU5G7HbAr8jsGWQxZCQIsgZo+FoRoCmNZPs2g1w
+jAiAouTmV2P0fVb7w1EPd0kr5dFFKHm0KGygkNrsE1KyU8tiWkvCxCuUvARyLIHJ
+UNgTtgJny4DVd+cshZxaNW+YMStUt4lHSOzGVmg+C9Y86LaNr7TGYUfq7pLp5fwN
+Qs3bbSXl5FD4aLvw/e9aDZTpcjijCHRwUJnrDkHuwQNQSiFfUXcyoXLU5klR7UuT
+7LLmD7NMxR6V7Wwsts8nDnKcJGX1nlHDShUyS8jGYxF9VXZeqj6KtKh2FiLBvsig
+vw+rOnB+5p1sjCLUhIU2pgXOHBeEfUOCJn/JGEwCB9l+gdL682avmPZoFMsG0O2x
++uADdHF9YaTVuTFV1DXkuSM1QZrqkpX4YzZbu8C6vay+Sh27sFEn86G7R2FFjl0R
+/T5Y2Pj5q/kl+3nXQBmUS/tqYmlvi2PPVQnS9qocyvRe6MaZkWrear+1QZRrhCNq
+QCxqXbF5JPsD2p1DQIUCDAPiA8lOXOuz7wEP/j2kHK4VdinfMlW2Fy7e2pHOeIAQ
+lXlBm8wEeNCyh3Gfbk+2acbo+l1qrz40bn6ZFQY8uzoibOJq8/Agoz20iUa/j0QL
+uBCk/zoutdvmgeO/TKywpgAYodNnwk0CmwH5et1U0PPMCH+/DHnUj+EKH7PXL9SL
+xMC9oV8OXH1TPyKJQxyOXeGvdvlY78tHvLMOOP41WVP2CsBnKmzFyQicU1/WCyY+
+UblTDQg4fO5amZC70goro+7uHymIyVl3xw/13u3fDb+ezRetUl/ELdYvewr6c5CS
+whq6RWYP7tOjpp3qV3EtoVXh+lPkjyuqcYsMobRpKtcwJGey/Y4b6opalwFmMSxx
+X2Oj7pgX6RmG7BfTJ6jEUlOBr3E4x6vhxBr9bml0mL0rObNRgJ4mougKjv8yhW8y
+ESPD/iADNzjQPHM30X7LNErgryB1x7xJ7FDFCpBfoC2p5Z3O39LLY6dyshdQM8ir
+5Zf6TZeRGTEXecHK6MJhCpqQCmT3VWbNTJpegGmueR9h/IIGonh5fzrcBfVhDLoI
+t4gHuG+cmYGrrgD+HnHSkPThKWzg0qoZWTAQZSuL5mEHzgWU1KX3YoxSGRDF1xkZ
+ZiZRLZ2HiRa1QHZB1tAah7BnFOKNYfxU7xZq6BvrPhcDly3y/MD/CIqpiNpVZ8QC
+0CrVPhwaShBxOCCI0usB1+oT7XGli4IYk7A+S7Br/W0Oszai1IhQC4DXYCrNJzaO
+gZn7BBQfFyKrW9PXBg86Lm2umxWCBVKQn3ATpi5MzCxkd82C/ohzVRJo2n/pbCY1
+5xgfcTr+DNWFOLgx++bJANkPVK68/Po/+kuGbngB0Qn+Kwe3Zu+TZoCVE0gaxPOP
+yxeor5swpO0R/zFWipanW/EHwgxB7nlMRtx6f3dILo/Cux75RmzX42DruPMyPfyN
+HsJJYw9+Vc1fABoBCzYQXBWzM14RnKkMw9P4otyc6w2xVzSzsNOVtdEsCzeVQ168
+s3LuZwrs04fg9bpjr0PFo1RNtuZ+33y8/yqgdH9AC+ABWYcBekrJsaAWPysK1xnW
+VpnwmPL8PHF3Mr+1AvBkJqORaVyXIWqILKzE7zkZcxEIMxFPDWM1jtDmgCmJFL/1
+g4cUxdnaDhcfi0F5sPtIKRrRZL6+5VHWgE+xj407d2TxeJOCxShyEtMMwObmKGPx
+9VamjV6OS66TobMQrcmKwTBwviGLE5FmajuXjn+bPMngsLgOc3gEqTGc2pRz4adc
+IoP9jGm2svJpxSz14LiL2CWtyncPDeyN6zHiC4KTRjfsTsX0fIUwW7esWgpyUmct
+RqDVXUPaE9HgJ0nL3LBvhRgK85C4vIx9FCQ/mzav6snQK05v8YV2lYNnk0w9dWUY
+ImmoctI0HZvTCMQS6khZjyzQITClS9H3KUjCXnEgM9WQkJZr8Egc5sm0U+HkhaXy
+oXqtjsWvGazecwN5NFvzBVPWsHKRHoLF/NLhud5bZiMZnSbq0wrhBEeLPP0j3ws1
+jAlxIGfwE1KSGmD+xW7fpEnEDR/vjdp9SDtKlsaia29fhp7aZojB2ZymsMBuoD/D
+ygu+/CmEmc2HWwsYVdtJ+aBcySoKsjBawKYh2vcdFwdkn1PiyZHqepjv0f8i+/Ld
+WbXIOvpNcbOf5gdw7f9NGIjMiy6KiLVSm+P8iUpeYzZ/ufcfbf/A4MQSsaXeBAem
+SQlEkB/luQsRco1CRW8n2g6Onr9Y8+uRDtNpIKTTuQ3Os5cU5j2zAe5KoDKtr5ph
+XOjqe44aPPq2VMHkKu95r4RMyvxV1PBFK6eYxK0j2ltq17ndmNvWry4zh7W37qO+
+OO/MoJB/ZOhM/TMdRR5P7xOEE3CL7oybgBL2DI9nM6S8AWFMeyrDu9kgd0PrhNtF
+U0bPycJtXlCi9Zy+mU9OP6JYxVegBi0d35qq+zOtrl35HtJq2Qbson9l4cSt733x
+2qLTgvFnjvoutZvteE3i2YkBlAi8rzke7SYuHRqH8zLUIanpewi7gS7tm14GWKRB
+ZkVhnCP8zawdyttrIsT4XdzD4gb9bCjDlgvfIYQ5zEapv64ngPNetOXUK/EDS7fz
+ngLWJGXtNVz388RrYajeEdB6IARVM1TihQP2xCPxgvzwSenwR3jHftOlILxh5upz
+2jkCkmcaVoiQ53GUWIzDfg2EDTpKWSjAG9uC0dVSc/Co59QKVBX47n2tRxzuP2k7
+VtoepPHl9vsPvDUn22cKkvLDmPqJqWAdMsnDg52eqdS7XIamQk/c8qBWXQBWDRbC
+Ckj/oQQDhSfFbZOI4H39PHgLXIV+RdGCFzx5QBA4gzak7g3mceFLNYKiiiHPhhZy
+b78RRjOWRHV9ErOO9NEnaHvSqMQdRR9egdiG1u0biLeAOo5BQAnDhUqC2LsLJtbu
+T5B4g1hnxxHKRMq89QmESl3hMd3F4UISX6JFJiLdAHQ4IG2w0RBhf3YEiZGnFcOf
+vY3/AMUQZ3HiK8E33C9YP0Hv6lzWkLea2Cm+4DLHx0mktsDwwS24SNkByQj3uTmw
+O44WE5zBIHQpHbBv6GHakMyN37AScLl0use0SfaOi5C24jdJ/TBjVqRXwEBkVr6a
+FSSeXz6FVqSbewUaavlKwb+XtYJk5X72fpaIUS9VrIvcdNI0++02JKYPVOsr6gvr
+pyCwV5LOB0nR+xqpjXZazg/2ocYK6kXJKf7C3sdX8Pm8M4FmOqPmUH3Qlx+1Q6v+
+Fbo8LAKhA88A0E4OAeXyKiXVbTWnZJmU4Hb8v0hrhKua24yaCBB+IQUut8D0e+mn
+6g8XsiNxy9oDZIJRIdZJwhB5dZ4wPvbdIQ6OKDRHBMT1XVSR4MbRoS724VAASxkV
++0JX4UW6zZ9ePz946uQox/qIal715/aWQ59Av7df2oGcyjZTfPvvE42QasvOwjao
+aLyuA090PQ+Fkg9fKnimUrxEn+6fPE+uHnaAi6f8PGjhpqNebTOshkdz044RVoKJ
+jrKwsa5IAWgxB+DT3pyO0agDqNX+Fykw3gjCullq6ueP9HdsfeGdQgPwCPnAMwxV
+o1n20QgQeDuuMhrtD3yW2YfUV7r0xCBvMCG8+CcXqsn7cN6Nh8Ptswlp6P7I+Wnr
+Wi7BF5ClPBjpuzFBcYp74KYitwODn4pO5cR8WHXFLHVqz4Q06s1lqIsCgAddYFt6
+mu/Vkmmc+/NKdxi7QZGiYeiWvu8rvthC6usuI0wEbrhg0yvU72g1IhKJO3P+Xu1h
+XFdhfKgEfYVS2n/iaKcxayIFOIz+EZ2gOPKDMk4cOhVjOiDFgK4RkAQ8A5WXui72
+0UW9IlitZRJ3HnWfZ6onXVWl1I3Ecy8CeJSyF+f9EQYq0IENTkezReX6+kEZm5tY
+h+JfzAENGa9qeP8nO5ODF+KVcn8DU5Ln58YClN7dQadT1do5+DLi/XGB144P4ekS
+FFcXuPwpe20b++HXML1j5QVaTO7ZoJEp93Q12t2UiNtPkI8fDyBMu9kqSBBVElgV
+G9KROI/IgQOpC7toMVON4Cv+VYh7sKD8pFizAPN1bZbGAtWfZ4UquBO9A8oKIhkv
+cRMxYuOaakQ4gzpSLfaj+XvJXTpte5rKbtjMcX1fCQLsQG5l2bELb7hPiGyPCKTk
+GwqsbsDGeVT/hC9IjJ3TzT8Y0jPQkR6oXBK/SJGI0K+WVnoAMRyNcIzsY94xqAhN
+pWJbWluMbr0TKFxK0DDPENoaaF0eDSSWpWbaZ89eKM6r0JajDt0urLlKlMW0kRK0
+sFbd7xGr9OgzGJzx/7wKtPKPNtaSpOxQ6nAo/2CkJoqweOHxtYbGmNppl4RBjhew
+PgDeIxrSBcQ7SRy6b8JN3QEH6RWPHCxdSIbxwjimtz8EJI10337mjmX08r32NxAP
+CzlCD3tS5tFGPJ05zzZXkoSfdvKT/KPHhTwcDOSiv5xaCKaS//4JZULOUVUpDrXJ
+CtdAAjASQnY/dIzSprNiJpsqGVGKI7BSJzXllP0xjpN3nVr+yGRvaGsibWHwLqKZ
+a8LjUDK093EWwv/8pukgxJdnaKlbxE/BYWzXcZdE0oqybdtb+eNy2jLIZLcKhtJM
+Llp9fR47foEd5AJz7B5L9Bdr+nVl4ubercO33hKk6AX5Ypx1Dg==
+=JQMQ
+-----END PGP MESSAGE-----
diff --git a/cluster/secrets/cipher/ca-admitomatic.key b/cluster/secrets/cipher/ca-admitomatic.key
new file mode 100644
index 0000000..54b03d2
--- /dev/null
+++ b/cluster/secrets/cipher/ca-admitomatic.key
@@ -0,0 +1,67 @@
+-----BEGIN PGP MESSAGE-----
+
+hQEMAzhuiT4RC8VbAQgAuAsJW4iaEdaYE4F5GJdufrPy4LvD6Gy4hRK0ek9/4wQH
+Loodf+P9MngWzSYDxoK7T26oRDKSC38KJf/AYUmwt7HGU1g5JAUKQvFp1NY+qr/G
+uC8LwvJHAcjBOg2HN1cVfQKhwsdTs9OcWfJT2v9iLlUi+YRnXILYm4eNFoRclulT
+59dpLujuqa+jyWIUPwQQEdQAeog5/kcEfzCwTWNvpnOX7NnLYH+/37BL7r6Fu1eT
+MFNNSZvgA/q3V7/EIOX1OFJr8DS/QkR4GW5HthjHI1s29TmW1NMyGFLK2WVIQwxw
+HiOolNdeZH7Bk3sj54/l/LCWY6L1GKLeU5pbw5ma6YUBDANcG2tp6fXqvgEIAMLQ
+nKahVTLUXPhevVFU9OasmGviyZuppkOlRrkEg+c0UcESw2vvgkqbm+txa5e09le2
+/Tahvm+sqT0iCTNKDt+f2L4kWvVFWmXCKgN7bLJxqHtLoivGOCZi8sdM1eZWf1Xm
+1abAF+QHgJYCEHNg7MJ1QxuE66iJ3jus603OS9ls9fxOjeQ0AL5661BkG3LUhfdJ
+DjTqWrXQda8+sCotiKUqjS96jj20JIw3VEkAJV9FR3y0xOQS+xvvGyJLs3QSC5S/
+EcucqpLyvYLxzCdd/79wwU3EgQtOOHaWfQxZmxbQbAQUMKO9viiAk7y0UUfeUzbL
+nuwljLbm0+iA8kSGta6FAgwDodoT8VqRl4UBD/4hd87HK3CWjKwBk6POVdn4wCJn
++2+5jLx8J6qZuI9NxK7UZybw6g3WuJVLaGqR41J+WU73tMhqOwJ+gsbUd+5VZLuW
+5LtiYU96Ha4kjBwE0+xYsZ8OctHJsRbuuNnCKKXdAZyW4Pzan09UkQ/51ICCVilD
+qQang9wXdDGbl8YXKeRPkayGFSiwcYsyMadEe0062Y6PHfivnXyxPD5mVYgQ1cc6
+tWWO14802F635MMOD0j2fj1D+mfkW00MmW1oUkXdmuHZ1abrN84XfQntSByWRRuH
+d4dDhWYgRQFRDQpT+V4ATMg7/kk9mghhhHInVCTlFLYx/4NqyUAHhIE7w4LO1Cfo
+N1Osw7v0KF8I+5qxZLKhGZwpF3z3OBW9fcq4G5YpRYSBrwYkadb6uwISNKFFw9w0
+xbSFsuvGwxOVNHyIAO42H+AFB3BBL0a8DFN8g196WEwnOf6LkrafENH0yRrvoVHp
+o2SbSqVU4r7omdBz3sg8MpL52Cs8g53KKCN1Te7i2s6HR4izZ4YIe92eZWTIcIGw
+g9zJj6JvrTCknzhpY5Aw3/M0V9vIknFSJ7HuT8zFDeP85ib3R7/9XsoOEFlHWtDM
+Gx29ny3ECsZl8KL9m8W65UilJxnsLOIKC0iVH44YeuWNySzYxzOg6bS1a5zlBOrU
+TVHq5uulE923AKlwSIUCDAPiA8lOXOuz7wEP/2P/6401xIcTSwKnXIBUHkYibY1G
+XsHi8yIFm175g1B0PIiYcjn/3fqYrUQ/y5go9bz3x7UBnrOKJUT3V2PzlqM0mh8R
+vFMrHmejMgNu8yDSzJ5ErEK5yXCRLRmPiWfMwwVdOZheb5yeLifY7yUT/H2WI4y7
+jwq8uobPBWlswrBo9j+ZgPtdrqQhbnjD+DidyOOvnnY3gKnoEvDoZlWo5dv30D4F
+l2A0o20FwN8KCqn7ySbylvTLRnBiMBP54ACc02t3Mi5Y3989eYmYDkgZfQLB0jhP
+4wmTr3d2mk3NgknHQ4RTBac1WuDcHVoANx+vqVcbM78Nba96yFiKBX+z8aV/qAoZ
+iRXdnbwgY6Ly3gg3F/+EYcY4VQ9XwaRoWbw31g74N40V/dUEPO/tXc6yVKVPg5Ma
+4hefQQIFJWJ9hh9RWYYtcuVuVlJw7sv8y6RadppZb3QLANmoybUKJE6MGWUEImSQ
+1D7krg8nL+0RoPFSnBZ3Gkvy6KpBq7VmgkgkS2vCeUoDmtJu8QwB36prGExd4Ith
+w7NNix/8AFW/AIctFdEkR1Ip75AWgV5m0YJjIkhW/OWeDJ5ErzEbuCrGup3U2V9Z
+w84ZizrkzqB+JBv0jo8i91gmWqBUBnQQ1Tg+6M6Mwc+A3Ha5xlRtplvRrl/2vQ7p
+JVsZwsszdpBiOMU00uoBpKZg6CPmslNBpH3INdPv9OoleNsXbC0biFoNBMiiOfgJ
+odDbpgWK9FzWmz+CjPbK7NNgrzRq9pyHTAetwcU7BBr2a87xdIQj/seva+iDC/aE
+YUpzxqxALM30Qt6TvYmAYaqDyhyEpuquO5nP/nvLDdAIjqfl8TlMtTsyjUuJ3k20
+kMfp58SzUAYW1dUoYE23jKz17ZAEzrMhVioGpFLCQ+Uhqeuk9fcDBv5Ur4j5ABd8
+mKSN801fJsYimhTQIq/mtabQ6/oR98S8D8R1E2ND4PS/LDwjR8AG8s+KPxLIpEem
+3Mkm1+6FshKFcvuz1KysBg/44YxD7Q4OeTnJ+gdBCD2091LtiQJK3k2jeQOWiQ09
+ixGh68uLFJSEq69e0Da1+jzJMmR9BE09BoAjZLzC7excOmRMPgq9xGtaWr+SmVr/
+1jC4pVnwXuvz7ISltMBpNDQp4/pNBZWrhlKa2OYtcO2thT2rrXRl/5iuW4smE3Cj
+vO+3Xith3eqFF/O0GiiSDywmOvPuph84TXr2u/gdkfU3WpNlwAPgUp0DlS3yHQAK
+xBjX//H5Jf2afAIkuM7uXvNZLkCJw7xpmsnZrbvah8Q3xg0gThFjExMz9dUZXqrO
+m986W76p57WIwDSZu887yijHEPCrivbNJhysj8XrG3vGY0WxnPQWkW29iC76w8Ji
+ARug35xvnV4GH7aJuI7iMI+wAkG1vexcGwSV99HnP6y2o+fo8tugIeYOjNGv5TkT
+7kDuaDrk8/dK5am6NP5nRv7hF/W5TKS8jAXOGqTylyzFOXcak5s2w7RUZmPZPBf5
+Bh8IEc0YPJiHWCX6rDPy9wlGZfqaIfdHdXa0XmeMMOvdXNJHObhPofyS+Q/dbKmh
+FYc21ppoeEyB+DeYfhgUgO5WVQhgrdU96Gt2yshIPjg/Utxbm5PZTQUdFga8TO3Q
+PWkEO0pXNXLe4B4yYuVvhLcaGpT7T49Kko9Y3dzdHsMRxrWUXdWIO3eSVcHU6bU8
+RBcGGzSB7pYJ25aHCSSHE6KFMPRi0ZD+lWsNJHWWjSO/cOMy9qCTdqQXx1OU2a/W
+WwK5WeaussnQVAGTbwKd9uJmgsYhfcobZuVDK0b+xdHcNSt/iZTMV/eCQjZ5Jpvu
+JHfSb+dqnaYlwqPix2oQQf1eqZntjj4rNG5Q26Wah17Rakv5XF/cR6eJkaaiYy6b
++/Dp9EqOSu4tifkYI7QTedYsgYZCZukCKt5+7fnZGSP+VP8Y74sVZ45r+nviWuvv
+r0TdDwl8eW1uOVY4JLyOKOYENzHc4AG/LquTMORNbYaUadmtZWXOQsGORWloJYOU
+bzvc0JmOZgaiRcaBbG9Tk1Fccrq7cXe+dwQqVtnfwKHEuPQaF/PVpqKtvjGO2YjA
+xoQIyJ/WnAK8hZnxndDDaPtL8FLW1aZrCKV84ZpIJVsLK36AlOZa8FodaTbKsdc6
+4TFjWZZpzCnfPMdnO4biQnQhv2wsQ4ddpohFzYJHbhY1gmSHdlBvEceDarzsKuYU
+wxqf4QM90ecS1PSTigU6BHzJzwtU1zsYonAI+YJdUsXlIp/84PbxG+1DWt2GKDG1
+vZt1Ca5RWdjGUhMAzkeIzcn5Wubsm1uhZhsJaHLnfioqJVjeGFvza5JN1tJMQGpZ
+Ns3VZ/nvOdPjOWSnVfD8hJc7TAeym0eY6M5JPAuKhURz6lEVMoeZ8poZ9Ox9YR/8
+nMTL5mHnrTtM7h+CK7BDMwkBY6fk+HykS74Yjr4LurguhWIVgHfDyrwsnBtZXvlo
+k1jmw+iwmZ8RUAmA7vlTWAfzGR2MzjFVznLhdvIp4SnEPpnAENdER1ZfD9m+m0Yc
+lQ==
+=Br5S
+-----END PGP MESSAGE-----
diff --git a/devtools/gerrit/kube/gerrit.libsonnet b/devtools/gerrit/kube/gerrit.libsonnet
index bebb3cf..00272a1 100644
--- a/devtools/gerrit/kube/gerrit.libsonnet
+++ b/devtools/gerrit/kube/gerrit.libsonnet
@@ -71,6 +71,11 @@
                     basePath  = git
                     canonicalWebUrl = https://%(domain)s/
                     serverId = %(identity)s
+                    reportBugUrl = https://b.hackerspace.pl/new
+
+                [commentlink "b"]
+                    match = [Bb]/(\\d+)
+                    link = https://b.hackerspace.pl/$1
 
                 [sshd]
                     advertisedAddress  = %(domain)s
diff --git a/devtools/issues/b/BUILD.bazel b/devtools/issues/b/BUILD.bazel
new file mode 100644
index 0000000..36933d8
--- /dev/null
+++ b/devtools/issues/b/BUILD.bazel
@@ -0,0 +1,42 @@
+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/devtools/issues/b",
+    visibility = ["//visibility:private"],
+    deps = ["@com_github_golang_glog//:go_default_library"],
+)
+
+go_binary(
+    name = "b",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":b",
+    ],
+    directory = "/devtools/issues/",
+)
+
+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/b",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
+
diff --git a/devtools/issues/b/main.go b/devtools/issues/b/main.go
new file mode 100644
index 0000000..8ef8150
--- /dev/null
+++ b/devtools/issues/b/main.go
@@ -0,0 +1,63 @@
+// A minimal redirector for b/123 style links to redmine.
+
+package main
+
+import (
+	"fmt"
+	"regexp"
+
+	"github.com/golang/glog"
+
+	"flag"
+	"net/http"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagListen  string
+	flagTarget  string
+	flagProject string
+
+	reIssue = regexp.MustCompile(`^/([0-9]+)$`)
+)
+
+func main() {
+	flag.StringVar(&flagListen, "b_listen", "0.0.0.0:8000", "Address to listen at")
+	flag.StringVar(&flagTarget, "b_target", "issues.hackerspace.pl", "Redmine instance address")
+	flag.StringVar(&flagProject, "b_project", "hswaw", "Redmine project name")
+	flag.Parse()
+
+	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		scheme := r.URL.Scheme
+		if scheme == "" {
+			scheme = "https"
+		}
+		if r.URL.Path == "/" {
+			http.Redirect(w, r, fmt.Sprintf("%s://%s/my/page", scheme, flagTarget), 302)
+			return
+		}
+		if r.URL.Path == "/new" {
+			http.Redirect(w, r, fmt.Sprintf("%s://%s/projects/%s/issues/new", scheme, flagTarget, flagProject), 302)
+			return
+		}
+		if matches := reIssue.FindStringSubmatch(r.URL.Path); len(matches) == 2 {
+			num := matches[1]
+			http.Redirect(w, r, fmt.Sprintf("%s://%s/issues/%s", scheme, flagTarget, num), 302)
+			return
+		}
+
+		fmt.Fprintf(w, `<!DOCTYPE html>
+			<title>🅱️</title>
+			<center><iframe width="1120" height="630" src="https://www.youtube.com/embed/el0PtDvg2AE?start=4994&autoplay=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></center>
+		`)
+	})
+
+	glog.Infof("Listening on %q...", flagListen)
+	err := http.ListenAndServe(flagListen, nil)
+	if err != nil {
+		glog.Exit(err)
+	}
+}
diff --git a/devtools/issues/prod.jsonnet b/devtools/issues/prod.jsonnet
index 2218716..14dbbba 100644
--- a/devtools/issues/prod.jsonnet
+++ b/devtools/issues/prod.jsonnet
@@ -18,6 +18,15 @@
             namespace: "redmine",
             domain: "issues.hackerspace.pl",
 
+            b: {
+                domains: [
+                    "b.hackerspace.pl",
+                    "b.hswaw.net",
+                    "xn--137h.hswaw.net",
+                    "xn--137h.hackerspace.pl",
+                ],
+            },
+
             storage+: {
                 endpoint: "https://object.ceph-waw3.hswaw.net",
                 bucket: "issues",
diff --git a/devtools/issues/redmine.libsonnet b/devtools/issues/redmine.libsonnet
index 420e488..9c1ed6a 100644
--- a/devtools/issues/redmine.libsonnet
+++ b/devtools/issues/redmine.libsonnet
@@ -18,6 +18,11 @@
             port: 5432,
         },
 
+        b: {
+            domains: [],
+            image: "registry.k0.hswaw.net/q3k/b:315532800-6cc2f867951e123909b23955cd7bcbcc3ec24f8a",
+        },
+
         storage: {
             endpoint: error "storage.endpoint must be set",
             region: error "storage.region must be set",
@@ -120,4 +125,59 @@
             ],
         },
     },
+
+    b: (if std.length(cfg.b.domains) > 0 then {
+        deployment: app.ns.Contain(kube.Deployment("b")) {
+            spec+: {
+                replicas: 3,
+                template+: {
+                    spec+: {
+                        containers_: {
+                            default: kube.Container("default") {
+                                image: "registry.k0.hswaw.net/q3k/b:315532800-6cc2f867951e123909b23955cd7bcbcc3ec24f8a",
+                                ports_: {
+                                    http: { containerPort: 8000 },
+                                },
+                                command: [
+                                    "/devtools/issues/b",
+                                ],
+                            },
+                        },
+                    },
+                },
+            },
+        },
+        svc: app.ns.Contain(kube.Service("b")) {
+            target_pod:: app.b.deployment.spec.template,
+        },
+        ingress: app.ns.Contain(kube.Ingress("b")) {
+            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.b.domains,
+                        secretName: "b-tls",
+                    },
+                ],
+                rules: [
+                    {
+                        host: domain,
+                        http: {
+                            paths: [
+                                { path: "/", backend: app.b.svc.name_port },
+                            ]
+                        },
+                    }
+                    for domain in cfg.b.domains
+                ],
+            },
+        }
+    } else {}),
+
 }
diff --git a/kube/kube.libsonnet b/kube/kube.libsonnet
index 929c6f2..8d7254a 100644
--- a/kube/kube.libsonnet
+++ b/kube/kube.libsonnet
@@ -17,6 +17,11 @@
         secret: { secretName: certificate.spec.secretName },
     },
 
+    ValidatingWebhookConfiguration(name): kube._Object("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", name) {
+        webhooks_:: error "webhooks_ must be defined",
+        webhooks: kube.mapToNamedList(self.webhooks_),
+    },
+
     # Add .Contain method to Namespaces, allowing for easy marking of particular
     # kube objects as contained in that namespace.
     Namespace(name): kube.Namespace(name) {
diff --git a/kube/prototext.libsonnet b/kube/prototext.libsonnet
new file mode 100644
index 0000000..419a511
--- /dev/null
+++ b/kube/prototext.libsonnet
@@ -0,0 +1,76 @@
+// An untyped prototext marshaller. Like std.manifestJson, but when you want to
+// emit proto-but-not-really.
+//
+// This supports recursively nested objects/arrays with string, number and bool
+// leaves. All hidden object fields are ignored. The given value must be an
+// object.
+//
+// One unintuitive aspect of proto(text) is that you cannot have
+// multidimensional arrays, ie. the following is forbidden:
+//
+//    manifestProtoText({foo: [[1, 2]]})
+//
+//    RUNTIME ERROR: manifestProtoText: at .foo[0]: double nested arrays are
+//    unsupported by prototext
+
+{
+    local top = self,
+
+    // manifestProtoText takes an object and returns a serialized prototext
+    // representation of it. If indentation is "", then a single-line prototext
+    // will be emitted, otherwise a multi-line prototext will be emitted with
+    // each object depth being indented by the indentation value.
+    manifestProtoText:: function(value, indentation="  ") top.recurse(value, indentation, 0, [""]),
+
+    // Available as std.repeat in jsonnet 0.15
+    repeat:: function(str, count) if count <= 0 then "" else (str + top.repeat(str, count-1)),
+
+    emit:: function(str, indentation, nindent) (
+        top.repeat(indentation, nindent) + str + (if indentation == "" then " " else "\n")
+    ),
+
+    fatal:: function(str, path) error "manifestProtoText: at %s: %s" % [std.join("", path), str],
+
+    // objectField emits a rendered `k: v` object field, potentially recursing
+    // back into recurse for printing the value of object. The value must not
+    // be an array, these are handled/flattened by recurse.
+    objectField:: function(name, value, indentation, nindent, path) (
+        if std.isObject(value) then (
+            top.emit("%s <" % [name], indentation, nindent) +
+            top.recurse(value, indentation, nindent+1, path) +
+            top.emit(">", indentation, nindent)
+        ) else if std.isArray(value) then (
+            top.fatal("double nested arrays are unsupported by prototext", path)
+        ) else if std.isFunction(value) then (
+            top.fatal("cannot manifest functions", path)
+        ) else if std.isBoolean(value) then (
+            top.emit("%s: %s" % [name, if value then "true" else "false"], indentation, nindent)
+        ) else if std.isNumber(value) then (
+            top.emit("%s: %s" % [name, std.toString(value)], indentation, nindent)
+        ) else if std.isString(value) then (
+            // Atempt to properly escape strings via JSON manifestation. Not
+            // entirely sure this always works.
+            top.emit("%s: %s" % [name, std.manifestJson(value)], indentation, nindent)
+        ) else (
+            top.fatal("unknown type %s" % [std.type(value)], path)
+        )
+    ),
+
+    // recurse returns the string representation of an object as prototext.
+    recurse:: function(value, indentation, nindent, path) (
+        if std.isObject(value) then std.join("", [
+            (
+                local field = value[fieldName];
+                if std.isArray(field) then std.join("", std.mapWithIndex(function(i, el) (
+                    local newPath = path + ["."+fieldName, "[%d]" % [i]];
+                    top.objectField(fieldName, el, indentation, nindent, newPath)
+                ), field)) else (
+                    local newPath = path + ["."+fieldName];
+                    top.objectField(fieldName, field, indentation, nindent, newPath)
+                )
+            )
+            for fieldName in std.objectFields(value)
+        ]) else
+        top.fatal("manifestProtoText can only manifest objects", path)
+    ),
+}
diff --git a/ops/sso/kube/sso.libsonnet b/ops/sso/kube/sso.libsonnet
index 33d5201..078c396 100644
--- a/ops/sso/kube/sso.libsonnet
+++ b/ops/sso/kube/sso.libsonnet
@@ -8,7 +8,7 @@
 
     cfg:: {
         namespace: "sso",
-        image: "registry.k0.hswaw.net/informatic/sso-v2@sha256:a44055a4f1d2a4e0708838b571f3a3c018f3b97adfea71ae0cf1df98246bf6cf",
+        image: "registry.k0.hswaw.net/informatic/sso-v2@sha256:3b277a8e2b3c3225d7da10aee37774266f9eb2aa536e7a390160f550b3556087",
         domain: error "domain must be set",
         database: {
             host: error "database.host must be set",