diff --git a/hswaw/capacifier/BUILD.bazel b/hswaw/capacifier/BUILD.bazel new file mode 100644 index 00000000..752064d5 --- /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 00000000..f2b77418 --- /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 00000000..34c62a1b --- /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 00000000..343209f9 --- /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 9a1bec7b..7918043e 100644 --- a/hswaw/kube/hswaw.jsonnet +++ b/hswaw/kube/hswaw.jsonnet @@ -8,6 +8,7 @@ local frab = import "frab.libsonnet"; 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 @@ local site = import "site.libsonnet"; pretalx: pretalx.cfg, cebulacamp: cebulacamp.cfg, site: site.cfg, + capacifier: capacifier.cfg, }, components: { @@ -33,6 +35,7 @@ local site = import "site.libsonnet"; 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 @@ local site = import "site.libsonnet"; 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 00000000..ec666cdc --- /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-----