hswaw/capacifier: rewrite it in go

This reimplements capacifier, one of the earliest
just-some-flask-code-on-boston-packets services, in Go.

It's a minimum reimplementation, as this service is generally deprecated
- but some stuff still depends on it. So we do away with capacifier v0's
bespoke rule language and just hardcode everything. It's not like any of
these rules ever changed, anyway.

This is not yet deployed.

Change-Id: Id65ef92784a524c32ae5223cd5460736ac683116
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1509
Reviewed-by: ironbound <ironbound@hackerspace.pl>
diff --git a/hswaw/capacifier/BUILD.bazel b/hswaw/capacifier/BUILD.bazel
new file mode 100644
index 0000000..752064d
--- /dev/null
+++ b/hswaw/capacifier/BUILD.bazel
@@ -0,0 +1,45 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["capacifier.go"],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/capacifier",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@in_gopkg_ldap_v3//:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "capacifier",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    directory = "/hswaw/capacifier/",
+    files = [
+        ":capacifier",
+    ],
+)
+
+container_image(
+    name = "runtime",
+    base = "@prodimage-bionic//image",
+    layers = [
+        ":layer_bin",
+    ],
+)
+
+container_push(
+    name = "push",
+    format = "Docker",
+    image = ":runtime",
+    registry = "registry.k0.hswaw.net",
+    repository = "q3k/capacifier",
+    tag = "1680390588",
+)
diff --git a/hswaw/capacifier/README.md b/hswaw/capacifier/README.md
new file mode 100644
index 0000000..f2b7741
--- /dev/null
+++ b/hswaw/capacifier/README.md
@@ -0,0 +1,23 @@
+capacifier
+===
+
+rewrite-in-go of code.haclerspace.pl/tomek/capacifier.
+
+This is one of the oldest API services at the Warsaw hackerspace, and exists
+solely to provide a generic 'is X a member of Y' functionality. It's generally
+deprecated (instead OIDC should be used as much as possible), but it's so
+entrenched into our infra that it's difficult to fully kill.
+
+While the previous implementation had a whole bespoke rule expression language,
+this implementation is stupidly simple, with all rules hardcoded.
+
+Running
+---
+
+Get the password for the capacifier service account from prod.
+
+Then:
+
+```
+    bazel run //hswaw/capacifier -- --ldap_bind_pw xxx
+```
diff --git a/hswaw/capacifier/capacifier.go b/hswaw/capacifier/capacifier.go
new file mode 100644
index 0000000..34c62a1
--- /dev/null
+++ b/hswaw/capacifier/capacifier.go
@@ -0,0 +1,203 @@
+package main
+
+import (
+	"crypto/tls"
+	"flag"
+	"fmt"
+	"net/http"
+	"regexp"
+	"strings"
+	"sync"
+
+	"github.com/golang/glog"
+	ldap "gopkg.in/ldap.v3"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+)
+
+type server struct {
+	mu   sync.Mutex
+	ldap *ldap.Conn
+}
+
+var reURL = regexp.MustCompile(`^/([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_]+)$`)
+
+func (s *server) handle(rw http.ResponseWriter, req *http.Request) {
+	if req.Method != "GET" {
+		rw.WriteHeader(http.StatusMethodNotAllowed)
+		fmt.Fprintf(rw, "method not allowed")
+		return
+	}
+
+	reqParts := reURL.FindStringSubmatch(req.URL.Path)
+	if len(reqParts) != 3 {
+		fmt.Fprintf(rw, "usage: GET /capability/user, eg. GET /staff/q3k")
+		return
+	}
+	c := reqParts[1]
+	u := reqParts[2]
+
+	res, err := s.capacify(c, u)
+	l := ""
+	r := ""
+	switch {
+	case err != nil:
+		l = fmt.Sprintf("%v", err)
+		r = "ERROR"
+		rw.WriteHeader(500)
+	case res:
+		l = "yes"
+		r = "YES"
+		rw.WriteHeader(200)
+	default:
+		l = "no"
+		r = "NO"
+		rw.WriteHeader(401)
+	}
+	glog.Infof("%s: GET /%s/%s: %s", req.RemoteAddr, c, u, l)
+	fmt.Fprintf(rw, "%s", r)
+}
+
+func (s *server) capacify(c, u string) (bool, error) {
+	switch c {
+	case "xmpp":
+		return s.checkLdap(u, "cn=xmpp-users,ou=Group,dc=hackerspace,dc=pl")
+	case "wiki_admin":
+		return s.checkLdap(u, "cn=admin,dc=wiki,dc=hackerspace,dc=pl")
+	case "twitter":
+		return s.checkLdap(u, "cn=twitter,ou=Group,dc=hackerspace,dc=pl")
+	case "lulzbot_access":
+		return s.checkLdap(u, "cn=lulzbot-access,ou=Group,dc=hackerspace,dc=pl")
+	case "staff":
+		return s.checkLdap(u, "cn=staff,ou=Group,dc=hackerspace,dc=pl")
+	case "kasownik_access":
+		return s.checkLdap(u, "cn=kasownik-access,ou=Group,dc=hackerspace,dc=pl")
+	case "starving":
+		return s.checkLdap(u, "cn=starving,ou=Group,dc=hackerspace,dc=pl")
+	case "fatty":
+		return s.checkLdap(u, "cn=fatty,ou=Group,dc=hackerspace,dc=pl")
+	case "member":
+		// Where we're going we don't need applicatives.
+		res, err := s.capacify("fatty", u)
+		if err != nil {
+			return false, err
+		}
+		if res {
+			return true, nil
+		}
+		return s.capacify("starving", u)
+	default:
+		return false, nil
+	}
+}
+
+func (s *server) getLdap() (*ldap.Conn, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.ldap == nil {
+		lconn, err := connectLdap()
+		if err != nil {
+			return nil, err
+		}
+		s.ldap = lconn
+	}
+	return s.ldap, nil
+}
+
+func (s *server) closeLdap() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.ldap != nil {
+		s.ldap.Close()
+		s.ldap = nil
+	}
+}
+
+func (s *server) checkLdap(u, dn string) (bool, error) {
+	lconn, err := s.getLdap()
+	if err != nil {
+		return false, err
+	}
+
+	if strings.ContainsAny(u, `\#+<>,;"=`) {
+		return false, nil
+	}
+	filter := fmt.Sprintf("(uniqueMember=uid=%s,ou=People,dc=hackerspace,dc=pl)", u)
+	search := ldap.NewSearchRequest(
+		dn,
+		ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
+		filter, []string{"dn", "cn"}, nil,
+	)
+	sr, err := lconn.Search(search)
+	if err != nil {
+		s.closeLdap()
+		return false, fmt.Errorf("search failed: %w", err)
+	}
+
+	for _, entry := range sr.Entries {
+		if entry.DN == dn {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagLDAPServer string
+	flagLDAPBindDN string
+	flagLDAPBindPW string
+	flagListen     string
+)
+
+func connectLdap() (*ldap.Conn, error) {
+	tlsConfig := &tls.Config{}
+	lconn, err := ldap.DialTLS("tcp", flagLDAPServer, tlsConfig)
+	if err != nil {
+		return nil, fmt.Errorf("ldap.DialTLS: %v", err)
+	}
+
+	if err := lconn.Bind(flagLDAPBindDN, flagLDAPBindPW); err != nil {
+		lconn.Close()
+		return nil, fmt.Errorf("ldap.Bind: %v", err)
+	}
+	return lconn, nil
+}
+
+func main() {
+	flag.StringVar(&flagListen, "api_listen", ":2137", "Address to listen on for API requests")
+	flag.StringVar(&flagLDAPServer, "ldap_server", "ldap.hackerspace.pl:636", "LDAP server address")
+	flag.StringVar(&flagLDAPBindDN, "ldap_bind_dn", "cn=capacifier,ou=Services,dc=hackerspace,dc=pl", "LDAP bind DN")
+	flag.StringVar(&flagLDAPBindPW, "ldap_bind_pw", "", "LDAP bind password")
+	flag.Parse()
+
+	if flagLDAPBindPW == "" {
+		glog.Exitf("-ldap_bind_pw must be set")
+	}
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	s := &server{}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", s.handle)
+
+	go func() {
+		glog.Infof("API Listening on %s", flagListen)
+		if err := http.ListenAndServe(flagListen, mux); err != nil {
+			glog.Exitf("API Listen failed: %v", err)
+		}
+	}()
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	<-m.Done()
+}
diff --git a/hswaw/kube/capacifier.libsonnet b/hswaw/kube/capacifier.libsonnet
new file mode 100644
index 0000000..343209f
--- /dev/null
+++ b/hswaw/kube/capacifier.libsonnet
@@ -0,0 +1,41 @@
+local mirko = import "../../kube/mirko.libsonnet";
+local kube = import "../../kube/kube.libsonnet";
+
+{
+    cfg:: {
+        ldapBindPassword: error "ldapBindPassword must be set!",
+        image: "registry.k0.hswaw.net/q3k/capacifier:1680390588",
+        fqdn: "capacifier.hackerspace.pl",
+    },
+
+    component(cfg, env):: mirko.Component(env, "capacifier") {
+        local capacifier = self,
+        cfg+: {
+            image: cfg.image,
+            container: capacifier.GoContainer("main", "/hswaw/capacifier/capacifier") {
+                env_: {
+                    BIND_PW: kube.SecretKeyRef(capacifier.secret, "bindPW"),
+                },
+                command+: [
+                    "-listen", "0.0.0.0:5000",
+                    "-ldap_bind_pw", "$(BIND_PW)",
+                ],
+            },
+            ports+: {
+                publicHTTP: {
+                    api: {
+                        port: 5000,
+                        dns: cfg.fqdn,
+                    }
+                },
+            },
+        },
+
+        secret: kube.Secret("capacifier") {
+            metadata+: capacifier.metadata,
+            data_: {
+                bindPW: cfg.ldapBindPassword,
+            },
+        },
+    },
+}
diff --git a/hswaw/kube/hswaw.jsonnet b/hswaw/kube/hswaw.jsonnet
index 9a1bec7..7918043 100644
--- a/hswaw/kube/hswaw.jsonnet
+++ b/hswaw/kube/hswaw.jsonnet
@@ -8,6 +8,7 @@
 local pretalx = import "pretalx.libsonnet";
 local cebulacamp = import "cebulacamp.libsonnet";
 local site = import "site.libsonnet";
+local capacifier = import "capacifier.libsonnet";
 
 {
     hswaw(name):: mirko.Environment(name) {
@@ -22,6 +23,7 @@
             pretalx: pretalx.cfg,
             cebulacamp: cebulacamp.cfg,
             site: site.cfg,
+            capacifier: capacifier.cfg,
         },
 
         components: {
@@ -33,6 +35,7 @@
             pretalx: pretalx.component(cfg.pretalx, env),
             cebulacamp: cebulacamp.component(cfg.cebulacamp, env),
             site: site.component(cfg.site, env),
+            capacifier: capacifier.component(cfg.capacifier, env),
         },
     },
 
@@ -75,6 +78,9 @@
             site+: {
                 webFQDN: "new.hackerspace.pl",
             },
+            capacifier+: {
+                ldapBindPassword: std.base64(std.split(importstr "secrets/plain/prod-capacifier-password", "\n")[0]),
+            },
         },
     },
 }
diff --git a/hswaw/kube/secrets/cipher/prod-capacifier-password b/hswaw/kube/secrets/cipher/prod-capacifier-password
new file mode 100644
index 0000000..ec666cd
--- /dev/null
+++ b/hswaw/kube/secrets/cipher/prod-capacifier-password
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+=SBGB
+-----END PGP MESSAGE-----