wow: init
This is a shitty MMORPG server. Private. Do not touch.
Change-Id: Iddfce069f5895632d305a73fcaa2d963e25dc600
diff --git a/personal/q3k/wow/lib.libsonnet b/personal/q3k/wow/lib.libsonnet
new file mode 100644
index 0000000..4a9517b
--- /dev/null
+++ b/personal/q3k/wow/lib.libsonnet
@@ -0,0 +1,301 @@
+local kube = import "../../../kube/kube.libsonnet";
+
+{
+ local wow = self,
+ local cfg = wow.cfg,
+ local ns = wow.ns,
+ cfg:: {
+ namespace: error "namespace must be set",
+ prefix: "",
+ images: {
+ acore: "registry.k0.hswaw.net/q3k/azerothcore-wowtlk:1606950998",
+ panel: "registry.k0.hswaw.net/q3k/panel:1607033741-f18a531f9b84c5b33653c8db5d64aaa0af337541",
+ },
+ db: {
+ local mkConfig = function(name) {
+ host: error ("db.%s.host must be set" % [name]),
+ port: error ("db.%s.prt must be set" % [name]),
+ user: error ("db.%s.user must be set" % [name]),
+ password: error ("db.%s.password must be set" % [name]),
+ database: "acore_%s" % [name],
+ },
+ auth: mkConfig("auth"),
+ world: mkConfig("world"),
+ characters: mkConfig("characters"),
+ },
+ panel: {
+ domain: error "panel.domain must be set",
+ soap: {
+ username: error "panel.soap.username must be set",
+ password: error "panel.soap.password must be set",
+ },
+ secret: error "panel.secret must be set",
+ oauth: {
+ clientID: error "panel.oauth.clientID must set",
+ clientSecret: error "panel.oauth.clientSecret must set",
+ redirectURL: "https://%s/callback" % [cfg.panel.domain],
+ },
+ motd: "",
+ },
+ overrides: {
+ authserver: {},
+ worldserver: {},
+ ahbot: {},
+ },
+ },
+
+ ns: kube.Namespace(cfg.namespace),
+
+ data: ns.Contain(kube.PersistentVolumeClaim(cfg.prefix + "data")) {
+ spec+: {
+ storageClassName: "waw-hdd-redundant-3",
+ accessModes: ["ReadWriteOnce"],
+ resources: {
+ requests: {
+ storage: "50Gi",
+ },
+ },
+ },
+ },
+
+ // Make a *DatabaseInfo string for use by acore config. These are not any real
+ // standardized DSN format, just some semicolon-delimited proprietary format.
+ local mkDbString = function(config) (
+ "%s;%d;%s;%s;%s" % [
+ config.host,
+ config.port,
+ config.user,
+ config.password,
+ config.database,
+ ]
+ ),
+
+ etc: ns.Contain(kube.Secret(cfg.prefix + "etc")) {
+ data: {
+ "worldserver.conf": std.base64(std.manifestIni({
+ sections: {
+ worldserver: {
+ RealmID: 1,
+ DataDir: "/data/current",
+ LoginDatabaseInfo: mkDbString(cfg.db.auth),
+ WorldDatabaseInfo: mkDbString(cfg.db.world),
+ CharacterDatabaseInfo: mkDbString(cfg.db.characters),
+ LogLevel: 2,
+
+ "Console.Enable": 0,
+ "Ra.Enable": 1,
+ "Ra.IP": "127.0.0.1",
+ "SOAP.Enabled": 1,
+ "SOAP.IP": "0.0.0.0",
+
+ } + cfg.overrides.worldserver,
+
+ },
+ })),
+ "mod_ahbot.conf": std.base64(std.manifestIni({
+ sections: {
+ worldserver: cfg.overrides.ahbot,
+ },
+ })),
+ "authserver.conf": std.base64(std.manifestIni({
+ sections: {
+ authserver: {
+ LoginDatabaseInfo: mkDbString(cfg.db.auth),
+ } + cfg.overrides.authserver,
+ },
+ })),
+ },
+ },
+
+ worldserverDeploy: ns.Contain(kube.Deployment(cfg.prefix + "worldserver")) {
+ spec+: {
+ template+: {
+ spec+: {
+ containers_: {
+ default: kube.Container("default") {
+ image: cfg.images.acore,
+ volumeMounts: [
+ { name: "data", mountPath: "/data" },
+ { name: "etc", mountPath: "/azeroth-server/etc/worldserver.conf", subPath: "worldserver.conf", },
+ { name: "etc", mountPath: "/azeroth-server/etc/mod_ahbot.conf", subPath: "mod_ahbot.conf", },
+ ],
+ command: [
+ "/entrypoint.sh",
+ "/azeroth-server/bin/worldserver",
+ ],
+ },
+ },
+ securityContext: {
+ runAsUser: 999,
+ runAsGroup: 999,
+ fsGroup: 999,
+ },
+ volumes_: {
+ data: kube.PersistentVolumeClaimVolume(wow.data),
+ etc: kube.SecretVolume(wow.etc),
+ },
+ },
+ },
+ },
+ },
+
+ authserverDeploy: ns.Contain(kube.Deployment(cfg.prefix + "authserver")) {
+ spec+: {
+ template+: {
+ spec+: {
+ containers_: {
+ default: kube.Container("default") {
+ image: cfg.images.acore,
+ volumeMounts_: {
+ etc: { mountPath: "/azeroth-server/etc/authserver.conf", subPath: "authserver.conf", },
+ },
+ command: [
+ "/azeroth-server/bin/authserver",
+ ],
+ },
+ },
+ securityContext: {
+ runAsUser: 999,
+ runAsGroup: 999,
+ },
+ volumes_: {
+ etc: kube.SecretVolume(wow.etc),
+ },
+ },
+ },
+ },
+ },
+
+ soapSvc: ns.Contain(kube.Service(cfg.prefix + "worldserver-soap")) {
+ target_pod:: wow.worldserverDeploy.spec.template,
+ spec+: {
+ ports: [
+ { name: "soap", port: 7878, targetPort: 7878, protocol: "TCP" },
+ ],
+ },
+ },
+ worldserverSvc: ns.Contain(kube.Service(cfg.prefix + "worldserver")) {
+ target_pod:: wow.worldserverDeploy.spec.template,
+ metadata+: {
+ annotations+: {
+ "metallb.universe.tf/allow-shared-ip": "%s/%ssvc" % [cfg.namespace, cfg.prefix],
+ },
+ },
+ spec+: {
+ ports: [
+ { name: "worldserver", port: 8085, targetPort: 8085, protocol: "TCP" },
+ ],
+ type: "LoadBalancer",
+ externalTrafficPolicy: "Cluster",
+ loadBalancerIP: cfg.address,
+ },
+ },
+ authserverSvc: ns.Contain(kube.Service(cfg.prefix + "authserver")) {
+ target_pod:: wow.authserverDeploy.spec.template,
+ metadata+: {
+ annotations+: {
+ "metallb.universe.tf/allow-shared-ip": "%s/%ssvc" % [cfg.namespace, cfg.prefix],
+ },
+ },
+ spec+: {
+ ports: [
+ { name: "authserver", port: 3724, targetPort: 3724, protocol: "TCP" },
+ ],
+ type: "LoadBalancer",
+ externalTrafficPolicy: "Cluster",
+ loadBalancerIP: cfg.address,
+ },
+ },
+
+ panelSecret: ns.Contain(kube.Secret(cfg.prefix + "panel-secret")) {
+ data+: {
+ soapPassword: std.base64(cfg.panel.soap.password),
+ secret: std.base64(cfg.panel.secret),
+ oauthSecret: std.base64(cfg.panel.oauth.clientSecret),
+ "motd.txt": std.base64(cfg.panel.motd),
+ },
+ },
+ panelData: ns.Contain(kube.PersistentVolumeClaim(cfg.prefix + "panel-data")) {
+ spec+: {
+ storageClassName: "waw-hdd-redundant-3",
+ accessModes: ["ReadWriteOnce"],
+ resources: {
+ requests: {
+ storage: "128Mi",
+ },
+ },
+ },
+ },
+ panelDeploy: ns.Contain(kube.Deployment(cfg.prefix + "panel")) {
+ spec+: {
+ template+: {
+ spec+: {
+ containers_: {
+ default: kube.Container("default") {
+ image: cfg.images.panel,
+ env_: {
+ SOAP_PASSWORD: kube.SecretKeyRef(wow.panelSecret, "soapPassword"),
+ SECRET: kube.SecretKeyRef(wow.panelSecret, "secret"),
+ OAUTH_SECRET: kube.SecretKeyRef(wow.panelSecret, "oauthSecret"),
+ },
+ command: [
+ "/personal/q3k/wow/panel/panel",
+ "-listen", "0.0.0.0:8080",
+ "-db", "/data/panel.db",
+ "-soap_address", "http://%s" % [wow.soapSvc.host_colon_port],
+ "-soap_password", "$(SOAP_PASSWORD)",
+ "-secret", "$(SECRET)",
+ "-oauth_client_id", cfg.panel.oauth.clientID,
+ "-oauth_client_secret", "$(OAUTH_SECRET)",
+ "-oauth_redirect_url", cfg.panel.oauth.redirectURL,
+ "-motd", "/secret/motd.txt",
+ ],
+ volumeMounts_: {
+ data: { mountPath: "/data" },
+ secret: { mountPath: "/secret" },
+ },
+ },
+ },
+ volumes_: {
+ data: kube.PersistentVolumeClaimVolume(wow.panelData),
+ secret: kube.SecretVolume(wow.panelSecret),
+ },
+ },
+ },
+ },
+ },
+ panelSvc: ns.Contain(kube.Service(cfg.prefix + "panel")) {
+ target_pod:: wow.panelDeploy.spec.template,
+ spec+: {
+ ports: [
+ { name: "web", port: 8080, targetPort: 8080, protocol: "TCP" },
+ ],
+ },
+ },
+ panelIngress: ns.Contain(kube.Ingress(cfg.prefix + "panel")) {
+ metadata+: {
+ annotations+: {
+ "kubernetes.io/tls-acme": "true",
+ "certmanager.k8s.io/cluster-issuer": "letsencrypt-prod",
+ },
+ },
+ spec+: {
+ tls: [
+ {
+ hosts: [cfg.panel.domain],
+ secretName: cfg.prefix + "panel-tls",
+ },
+ ],
+ rules: [
+ {
+ host: cfg.panel.domain,
+ http: {
+ paths: [
+ { path: "/", backend: wow.panelSvc.name_port },
+ ],
+ },
+ }
+ ],
+ },
+ },
+}
diff --git a/personal/q3k/wow/panel/BUILD.bazel b/personal/q3k/wow/panel/BUILD.bazel
new file mode 100644
index 0000000..44162a4
--- /dev/null
+++ b/personal/q3k/wow/panel/BUILD.bazel
@@ -0,0 +1,51 @@
+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 = [
+ "main.go",
+ "soap.go",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/personal/q3k/wow/panel",
+ visibility = ["//visibility:private"],
+ deps = [
+ "@com_github_boltdb_bolt//:go_default_library",
+ "@com_github_coreos_go_oidc//:go_default_library",
+ "@com_github_golang_glog//:go_default_library",
+ "@com_github_gorilla_sessions//:go_default_library",
+ "@org_golang_x_oauth2//:go_default_library",
+ ],
+)
+
+go_binary(
+ name = "panel",
+ embed = [":go_default_library"],
+ visibility = ["//visibility:public"],
+)
+
+container_layer(
+ name = "layer_bin",
+ files = [
+ ":panel",
+ ],
+ directory = "/personal/q3k/wow/panel/",
+)
+
+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/panel",
+ tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/personal/q3k/wow/panel/main.go b/personal/q3k/wow/panel/main.go
new file mode 100644
index 0000000..cd1f586
--- /dev/null
+++ b/personal/q3k/wow/panel/main.go
@@ -0,0 +1,466 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/boltdb/bolt"
+ oidc "github.com/coreos/go-oidc"
+ "github.com/golang/glog"
+ "github.com/gorilla/sessions"
+ "golang.org/x/oauth2"
+)
+
+var (
+ flagSOAPAddress string
+ flagSOAPUsername string
+ flagSOAPPassword string
+ flagListen string
+ flagSecret string
+ flagOAuthClientID string
+ flagOAuthClientSecret string
+ flagOAuthRedirectURL string
+ flagDB string
+ flagMOTD string
+)
+
+func init() {
+ flag.Set("logtostderr", "true")
+}
+
+func main() {
+ flag.StringVar(&flagSOAPAddress, "soap_address", "http://127.0.0.1:7878", "Address of AC SOAP server")
+ flag.StringVar(&flagSOAPUsername, "soap_username", "test1", "SOAP username")
+ flag.StringVar(&flagSOAPPassword, "soap_password", "", "SOAP password")
+ flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "HTTP listen address")
+ flag.StringVar(&flagSecret, "secret", "", "Cookie secret")
+ flag.StringVar(&flagOAuthClientID, "oauth_client_id", "", "OAuth client ID")
+ flag.StringVar(&flagOAuthClientSecret, "oauth_client_secret", "", "OAuth client secret")
+ flag.StringVar(&flagOAuthRedirectURL, "oauth_redirect_url", "", "OAuth redirect URL")
+ flag.StringVar(&flagDB, "db", "db.db", "Path to database")
+ flag.StringVar(&flagMOTD, "motd", "", "Path to MOTD")
+ flag.Parse()
+
+ if flagSecret == "" {
+ glog.Exitf("-secret must be set")
+ }
+
+ var err error
+ var motd []byte
+ if flagMOTD == "" {
+ glog.Warningf("no MOTD defined, set -motd to get one")
+ } else {
+ motd, err = ioutil.ReadFile(flagMOTD)
+ if err != nil {
+ glog.Exitf("cannot read MOTD %q: %v", flagMOTD, err)
+ }
+ }
+
+ db, err := bolt.Open(flagDB, 0600, nil)
+ if err != nil {
+ glog.Exitf("opening database failed: %v", err)
+ }
+
+ provider, err := oidc.NewProvider(context.Background(), "https://sso.hackerspace.pl")
+ if err != nil {
+ glog.Exitf("newprovider: %v", err)
+ }
+ oauth2Config := oauth2.Config{
+ ClientID: flagOAuthClientID,
+ ClientSecret: flagOAuthClientSecret,
+ RedirectURL: flagOAuthRedirectURL,
+
+ // Discovery returns the OAuth2 endpoints.
+ Endpoint: provider.Endpoint(),
+
+ // "openid" is a required scope for OpenID Connect flows.
+ Scopes: []string{oidc.ScopeOpenID, "profile:read"},
+ }
+
+ err = db.Update(func(tx *bolt.Tx) error {
+ for _, name := range []string{
+ "emailToAccount",
+ } {
+ _, err := tx.CreateBucketIfNotExists([]byte(name))
+ if err != nil {
+ return fmt.Errorf("create bucket %q: %s", name, err)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ glog.Exitf("db setup failed: %v", err)
+ }
+
+ s := &server{
+ db: db,
+ store: sessions.NewCookieStore([]byte(flagSecret)),
+ oauth2: &oauth2Config,
+ motd: string(motd),
+ }
+
+ http.HandleFunc("/", s.viewIndex)
+ http.HandleFunc("/login", s.viewLogin)
+ http.HandleFunc("/oauth", s.viewOauth)
+ http.HandleFunc("/callback", s.viewOauthCallback)
+ http.HandleFunc("/setup", s.viewOauthSetup)
+ http.HandleFunc("/reset", s.viewReset)
+ http.HandleFunc("/logout", s.viewLogout)
+
+ err = http.ListenAndServe(flagListen, nil)
+ if err != nil {
+ glog.Exitf("ListenAndServe: %v", err)
+ }
+}
+
+type server struct {
+ db *bolt.DB
+ store *sessions.CookieStore
+ oauth2 *oauth2.Config
+ motd string
+
+ onlineLock sync.RWMutex
+ onlineData []playerinfo
+ onlineDeadline time.Time
+}
+
+var (
+ tLogin = template.Must(template.New("login").Parse(`<html>
+<title>super wow - who are you?</title>
+<body>
+<pre>
+
+ ___ _ _ _ __ ___ _ __ __ _______ __
+/ __| | | | '_ \ / _ \ '__| \ \ /\ / / _ \ \ /\ / /
+\__ \ |_| | |_) | __/ | \ V V / (_) \ V V /
+|___/\__,_| .__/ \___|_| \_/\_/ \___/ \_/\_/
+ |_|
+ _ _ _ _ _ _
+| | _____ | | ___ (_)___ _ _ __ | | _____ | |_ _| |__
+| |/ / _ \| |/ _ \_ / _' | '_ \| |/ / _ \ | | | | | '_ \
+| < (_) | | __// / (_| | | | | < (_) | | | |_| | |_) |
+|_|\_\___/|_|\___/___\__,_|_| |_|_|\_\___/ |_|\__,_|_.__/
+
+ _ _ __
+| | _____ | | ___ __ _ ___ \ \
+| |/ / _ \| |/ _ \/ _' |/ _ \ (_) |
+| < (_) | | __/ (_| | (_) | _| |
+|_|\_\___/|_|\___|\__, |\___/ (_) |
+ |___/ /_/
+
+</pre>
+<a href="/oauth">Sign in (or create new account) with HSWAW SSO</a>.<br/>
+
+<p>
+Not a hswaw member? Talk to q3k.
+</p>
+</body>`))
+ tSetup = template.Must(template.New("setup").Parse(`<html>
+<title>super wow - setup account</title>
+<body>
+<b>hi, please provide details for your new WoW account</b><br/>
+pick any username you want, pick a 3-16 character password that isn't the same as your sso password (duh)<br />
+(this isn't your character name, this will only be used to log into WoW)
+{{ if .Error }}
+<br /><b>error</b>: {{ .Error }}<br />
+{{ end }}
+<form method="post" action="/setup">
+username:<input name="username" value={{ .Username }}></input><br/>
+password:<input name="password" type="password"></input><br/>
+<input type=submit value="create account"/>
+</form>
+</body>`))
+ tIndex = template.Must(template.New("index").Parse(`<html>
+<title>super wow</title>
+<style type="text/css">
+ body {
+ background-color: #fff;
+ }
+ table, th, td {
+ background-color: #eee;
+ padding: 0.2em 0.4em 0.2em 0.4em;
+ }
+ table th {
+ background-color: #c0c0c0;
+ }
+ table {
+ background-color: #fff;
+ border-spacing: 0.2em;
+ }
+</style>
+<body>
+<b>Hello, {{ .Username }}.</b></br>
+<a href="/logout">Log out.</a><br />
+{{ .MOTD }}
+<p>
+Your account name is {{ .Username }}. Use the password that you entered when logging in via SSO for the first time, or <form action="/reset" method="POST"><input name="password" type="password" /><input type="submit" value="set a new password"/>.</form>
+</p>
+<b>Currently in-game:</b>
+<table>
+<tr><th>Account</th><th>Character</th></tr>
+{{ range .Online }}
+<tr><td>{{ .Account }}</td><td>{{ .Character }}</td></tr>
+{{ end }}
+</table>
+</body>`))
+)
+
+func (s *server) session(r *http.Request) *sessions.Session {
+ session, _ := s.store.Get(r, sessionName)
+ return session
+}
+
+func (s *server) sessionGet(r *http.Request, k string) string {
+ v, ok := s.session(r).Values[k]
+ if !ok {
+ return ""
+ }
+ v2, ok := v.(string)
+ if !ok {
+ return ""
+ }
+ return v2
+}
+
+func (s *server) sessionPut(w http.ResponseWriter, r *http.Request, k, v string) {
+ session := s.session(r)
+ session.Values[k] = v
+ session.Save(r, w)
+}
+
+func (s *server) online(ctx context.Context) []playerinfo {
+ s.onlineLock.RLock()
+ if s.onlineData == nil || time.Now().After(s.onlineDeadline) {
+ s.onlineLock.RUnlock()
+ s.onlineLock.Lock()
+ data, err := onlinelist(ctx)
+ if err != nil {
+ glog.Errorf("onlinelist fatch failed: %v", err)
+ s.onlineDeadline = time.Now().Add(10 * time.Second)
+ } else {
+ s.onlineData = data
+ s.onlineDeadline = time.Now().Add(60 * time.Second)
+ }
+ s.onlineLock.Unlock()
+ s.onlineLock.RLock()
+ }
+
+ res := make([]playerinfo, len(s.onlineData))
+ for i, pi := range s.onlineData {
+ res[i] = pi
+ }
+ s.onlineLock.RUnlock()
+ return res
+}
+
+func (s *server) viewIndex(w http.ResponseWriter, r *http.Request) {
+ account := s.sessionGet(r, "account")
+ if account == "" {
+ http.Redirect(w, r, "/login", 302)
+ return
+ }
+ err := tIndex.Execute(w, map[string]interface{}{
+ "Username": account,
+ "Online": s.online(r.Context()),
+ "MOTD": template.HTML(s.motd),
+ })
+ if err != nil {
+ glog.Errorf("/: %v", err)
+ return
+ }
+}
+
+const sessionName = "wow"
+
+func (s *server) viewLogin(w http.ResponseWriter, r *http.Request) {
+ account := s.sessionGet(r, "account")
+ if account != "" {
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ err := tLogin.Execute(w, nil)
+ if err != nil {
+ glog.Errorf("/login: %v", err)
+ return
+ }
+}
+
+func (s *server) viewOauth(w http.ResponseWriter, r *http.Request) {
+ stateBytes := make([]byte, 8)
+ _, err := rand.Read(stateBytes)
+ if err != nil {
+ glog.Errorf("/oauth: random: %v", err)
+ return
+ }
+ state := hex.EncodeToString(stateBytes)
+ s.sessionPut(w, r, "ostate", state)
+ url := s.oauth2.AuthCodeURL(state)
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func (s *server) viewOauthCallback(w http.ResponseWriter, r *http.Request) {
+ if r.FormValue("errors") != "" {
+ fmt.Fprintf(w, "Errors: %s", r.FormValue("errors"))
+ return
+ }
+ state := s.sessionGet(r, "ostate")
+ if state == "" {
+ glog.Errorf("No state")
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ if state != r.FormValue("state") {
+ glog.Errorf("Invalid state")
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ oauth2Token, err := s.oauth2.Exchange(r.Context(), r.FormValue("code"))
+ if err != nil {
+ glog.Errorf("Exchange failed: %v", err)
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ client := s.oauth2.Client(r.Context(), oauth2Token)
+ res, err := client.Get("https://sso.hackerspace.pl/api/1/userinfo")
+ if err != nil {
+ glog.Errorf("Userinfo failed: %v", err)
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ defer res.Body.Close()
+ data, _ := ioutil.ReadAll(res.Body)
+
+ ui := userinfo{}
+ err = json.Unmarshal(data, &ui)
+ if err != nil || ui.Email == "" {
+ glog.Errorf("Userinfo unarshal failed: %v", err)
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+
+ account, err := s.accountForEmail(ui.Email)
+ if err != nil {
+ glog.Errorf("account get failed: %v", err)
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+ if account != "" {
+ s.sessionPut(w, r, "account", account)
+ http.Redirect(w, r, "/", 302)
+ } else {
+ s.sessionPut(w, r, "email", ui.Email)
+ http.Redirect(w, r, "/setup", 302)
+ }
+}
+
+func (s *server) viewOauthSetup(w http.ResponseWriter, r *http.Request) {
+ email := s.sessionGet(r, "email")
+ if email == "" {
+ glog.Errorf("No email")
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+
+ if r.Method == "GET" {
+ tSetup.Execute(w, nil)
+ return
+ }
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ if !reAccount.MatchString(username) {
+ tSetup.Execute(w, map[string]string{
+ "Username": username,
+ "Error": "Invalid username - must be 3-16 a-z 0-9 - _",
+ })
+ return
+ }
+ if !rePassword.MatchString(password) {
+ tSetup.Execute(w, map[string]string{
+ "Username": username,
+ "Error": "Invalid password - must be 3-16 a-z A-Z 0-9 - _",
+ })
+ return
+ }
+
+ // this races. ugh. no way to list users. yolo.
+ err := createAccount(r.Context(), username, password)
+ if err != nil {
+ tSetup.Execute(w, map[string]string{
+ "Username": username,
+ "Error": "Account already exists, pick a different username",
+ })
+ return
+ }
+
+ err = s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("emailToAccount"))
+ v := string(b.Get([]byte(email)))
+ if v != "" {
+ s.sessionPut(w, r, "account", v)
+ http.Redirect(w, r, "/", 302)
+ return nil
+ }
+ b.Put([]byte(email), []byte(username))
+ s.sessionPut(w, r, "account", username)
+ http.Redirect(w, r, "/", 302)
+ return nil
+ })
+ if err != nil {
+ glog.Errorf("setup tx: %v", err)
+ http.Redirect(w, r, "/", 302)
+ }
+}
+func (s *server) viewReset(w http.ResponseWriter, r *http.Request) {
+ account := s.sessionGet(r, "account")
+ if account == "" {
+ glog.Errorf("No account")
+ http.Redirect(w, r, "/", 302)
+ return
+ }
+
+ password := r.FormValue("password")
+ if !rePassword.MatchString(password) {
+ fmt.Fprintf(w, "pick a 3-16 password with not too many special chars")
+ return
+ }
+
+ // this races. ugh. no way to list users. yolo.
+ err := ensureAccount(r.Context(), account, password)
+ if err != nil {
+ glog.Errorf("ensureAccount(%q, _): %v", account, err)
+ fmt.Fprintf(w, "something went wrong, lol")
+ return
+ }
+ fmt.Fprintf(w, "new password set.")
+}
+
+func (s *server) viewLogout(w http.ResponseWriter, r *http.Request) {
+ s.sessionPut(w, r, "account", "")
+ s.sessionPut(w, r, "email", "")
+ http.Redirect(w, r, "/", 302)
+}
+
+func (s *server) accountForEmail(email string) (string, error) {
+ res := ""
+ err := s.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("emailToAccount"))
+ v := b.Get([]byte(email))
+ res = string(v)
+ return nil
+ })
+ return res, err
+}
+
+type userinfo struct {
+ Email string `json:"email"`
+}
diff --git a/personal/q3k/wow/panel/soap.go b/personal/q3k/wow/panel/soap.go
new file mode 100644
index 0000000..1019861
--- /dev/null
+++ b/personal/q3k/wow/panel/soap.go
@@ -0,0 +1,215 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/golang/glog"
+)
+
+type Envelope struct {
+ XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"`
+ Body *Body
+}
+
+type Body struct {
+ XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"`
+ Request *Request
+ Response *Response
+ Fault *Fault
+}
+
+type Request struct {
+ XMLName xml.Name `xml:"urn:AC executeCommand"`
+ Command string `xml:"command"`
+}
+type Response struct {
+ XMLName xml.Name `xml:"urn:AC executeCommandResponse"`
+ Result string `xml:"result"`
+}
+
+type Fault struct {
+ XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Fault"`
+ Code string `xml:"faultcode"`
+ String string `xml:"faultstring"`
+}
+
+type commandRes struct {
+ result string
+ fault string
+}
+
+var (
+ reAccount = regexp.MustCompile(`^[a-z0-9\-_\.]{3,16}$`)
+ rePassword = regexp.MustCompile(`^[a-zA-Z0-9\-_\.]{3,16}$`)
+)
+
+func createAccount(ctx context.Context, name, password string) error {
+ if !reAccount.MatchString(name) {
+ return fmt.Errorf("invalid account name")
+ }
+ if !rePassword.MatchString(password) {
+ return fmt.Errorf("invalid password name")
+ }
+ res, err := runCommand(ctx, fmt.Sprintf("account create %s %s", name, password))
+ if err != nil {
+ glog.Errorf("Account create: %v", err)
+ return fmt.Errorf("server unavailable")
+ }
+ if res.result == fmt.Sprintf("Account created: %s", name) {
+ glog.Infof("Created account %q", name)
+ return nil
+ }
+ glog.Errorf("Account create fault: %q/%q", res.fault, res.result)
+ return fmt.Errorf("server error")
+}
+
+func ensureAccount(ctx context.Context, name, password string) error {
+ if !reAccount.MatchString(name) {
+ return fmt.Errorf("invalid account name")
+ }
+ if !rePassword.MatchString(password) {
+ return fmt.Errorf("invalid password name")
+ }
+ res, err := runCommand(ctx, fmt.Sprintf("account create %s %s", name, password))
+ if err != nil {
+ glog.Errorf("Account create: %v", err)
+ return fmt.Errorf("server unavailable")
+ }
+ if res.result == fmt.Sprintf("Account created: %s", name) {
+ glog.Infof("Created account %q", name)
+ return nil
+ }
+ if res.fault != "Account with this name already exist!" {
+ glog.Errorf("Account create fault: %q/%q", res.fault, res.result)
+ return fmt.Errorf("server error")
+ }
+
+ res, err = runCommand(ctx, fmt.Sprintf("account set password %s %s %s", name, password, password))
+ if res.result == "The password was changed" {
+ glog.Infof("Updated password for account %q", name)
+ return nil
+ }
+ glog.Infof("password update fault: %q/%q", res.fault, res.result)
+ return fmt.Errorf("server error")
+}
+
+func runCommand(ctx context.Context, cmd string) (*commandRes, error) {
+ data, err := xml.Marshal(&Envelope{
+ Body: &Body{
+ Request: &Request{
+ Command: cmd,
+ },
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("marshal: %w", err)
+ }
+ buf := bytes.NewBuffer(data)
+ req, err := http.NewRequestWithContext(ctx, "POST", flagSOAPAddress, buf)
+ if err != nil {
+ return nil, fmt.Errorf("NewRequest(POST, %q): %w", flagSOAPAddress, err)
+ }
+
+ req.SetBasicAuth(flagSOAPUsername, flagSOAPPassword)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("req.Do: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBytes, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("ReadAll response: %w", err)
+ }
+
+ respEnvelope := Envelope{}
+ err = xml.Unmarshal(respBytes, &respEnvelope)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal: %w", err)
+ }
+
+ if respEnvelope.Body == nil {
+ return nil, fmt.Errorf("no body returned")
+ }
+
+ if respEnvelope.Body.Fault != nil {
+ fault := respEnvelope.Body.Fault
+ if fault.Code == "SOAP-ENV:Client" {
+ return &commandRes{
+ fault: strings.TrimSpace(fault.String),
+ }, nil
+ }
+ return nil, fmt.Errorf("SOAP error %q: %v", fault.Code, fault.String)
+ }
+
+ result := ""
+ if respEnvelope.Body.Response != nil {
+ result = respEnvelope.Body.Response.Result
+ }
+
+ return &commandRes{
+ result: strings.TrimSpace(result),
+ }, nil
+}
+
+type playerinfo struct {
+ Account string
+ Character string
+}
+
+func onlinelist(ctx context.Context) ([]playerinfo, error) {
+ res, err := runCommand(ctx, "account onlinelist")
+ if err != nil {
+ glog.Errorf("onlinelist: %v", err)
+ return nil, fmt.Errorf("server unavailable")
+ }
+ if res.fault != "" {
+ glog.Errorf("onlinelist fault: %q", res.fault)
+ return nil, fmt.Errorf("server unavailable")
+ }
+
+ lines := strings.Split(res.result, "\n")
+ header := false
+ var pi []playerinfo
+ for _, line := range lines {
+ switch {
+ case strings.HasPrefix(line, "-="):
+ continue
+ case strings.HasPrefix(line, "-["):
+ default:
+ glog.Warningf("unparseable line %q", line)
+ continue
+ }
+ if !header {
+ header = true
+ continue
+ }
+ if len(line) != 69 {
+ glog.Warningf("wrong line length: %q", line)
+ continue
+ }
+ account := strings.ToLower(strings.TrimSpace(line[2:18]))
+ if line[18:20] != "][" {
+ glog.Warningf("unparseable line %q (wrong sep1)", line)
+ continue
+ }
+ character := strings.TrimSpace(line[20:32])
+ if line[32:34] != "][" {
+ glog.Warningf("unparseable line %q (wrong sep2)", line)
+ continue
+ }
+ pi = append(pi, playerinfo{
+ Account: account,
+ Character: character,
+ })
+ }
+ glog.Infof("Onlinelist: %v", pi)
+ return pi, nil
+}
diff --git a/personal/q3k/wow/prod.jsonnet b/personal/q3k/wow/prod.jsonnet
new file mode 100644
index 0000000..20912fb
--- /dev/null
+++ b/personal/q3k/wow/prod.jsonnet
@@ -0,0 +1,90 @@
+local wow = import "lib.libsonnet";
+local mysql = import "../../../kube/mysql.libsonnet";
+
+{
+ q3k: wow {
+ local sqlPassword = (std.split(importstr "secrets/plain/mysql-root-password", "\n"))[0],
+ local soapPassword = (std.split(importstr "secrets/plain/soap-password", "\n"))[0],
+ local panelSecret = (std.split(importstr "secrets/plain/panel-secret", "\n"))[0],
+ local oauthSecret = (std.split(importstr "secrets/plain/oauth-secret", "\n"))[0],
+ local motd = importstr "secrets/plain/motd.txt",
+
+ local wow = self,
+ local cfg = self.cfg,
+ cfg+:: {
+ namespace: "personal-q3k",
+ prefix: "wow-",
+ address: "185.236.240.62",
+ db+: {
+ // Run everything as mysql root, #yolo.
+ local mkConfig = function(name) {
+ host: wow.mysql.svc.host,
+ port: wow.mysql.svc.port,
+ user: "root",
+ password: sqlPassword,
+ database: "acore_%s" % [name],
+ },
+ auth+: mkConfig("auth"),
+ world+: mkConfig("world"),
+ characters+: mkConfig("characters"),
+ },
+ panel+: {
+ domain: "wow.q3k.org",
+ soap+: {
+ username: "test1",
+ password: soapPassword,
+ },
+ secret: panelSecret,
+ oauth+: {
+ clientID: "56403ef3-df6f-4893-b475-d6c18284ed42",
+ clientSecret: oauthSecret,
+ },
+ motd: motd,
+ },
+ overrides+: {
+ worldserver: {
+ RealmZone: 8,
+ Motd: "Welcome to Pabianice. Enjoy your grind.",
+
+ "Rate.Drop.Item.Poor": 2,
+ "Rate.Drop.Item.Normal": 2,
+ "Rate.Drop.Item.Uncommon": 10,
+ "Rate.Drop.Item.Rare": 10,
+ "Rate.Drop.Item.Epic": 10,
+ "Rate.Drop.Item.Legendary": 10,
+ "Rate.Drop.Item.Artifact": 10,
+ "Rate.Drop.Item.Referenced": 10,
+ "Rate.Drop.Money": 10,
+ "Rate.XP.Kill": 5,
+ "Rate.XP.Explore": 5,
+ "Rate.XP.BattlegroundKill": 10,
+ "Rate.MoveSpeed": 2,
+ "SkillGain.Crafting": 5,
+ "SkillGain.Defense": 5,
+ "SkillGain.Gathering": 5,
+ "SkillGain.Weapon": 5,
+
+ "MinPetitionSigns": 5,
+ "GM.AllowFriend": 1,
+ },
+ ahbot: {
+ "AuctionHouseBot.EnableSeller": 1,
+ "AuctionHouseBot.EnableBuyer": 1,
+ "AuctionHouseBot.Account": 21,
+ "AuctionHouseBot.GUID": 12,
+ },
+ },
+ },
+
+ // Run a single shitty database.
+ mysql: mysql {
+ cfg+:: {
+ namespace: cfg.namespace,
+ appName: "wow",
+ prefix: cfg.prefix,
+ password: sqlPassword,
+ user: "acore",
+ },
+ },
+ },
+}
diff --git a/personal/q3k/wow/secrets/cipher/motd.txt b/personal/q3k/wow/secrets/cipher/motd.txt
new file mode 100644
index 0000000..2cf6885
--- /dev/null
+++ b/personal/q3k/wow/secrets/cipher/motd.txt
@@ -0,0 +1,46 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+=+xOs
+-----END PGP MESSAGE-----
diff --git a/personal/q3k/wow/secrets/cipher/mysql-root-password b/personal/q3k/wow/secrets/cipher/mysql-root-password
new file mode 100644
index 0000000..f323e61
--- /dev/null
+++ b/personal/q3k/wow/secrets/cipher/mysql-root-password
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+=BH/F
+-----END PGP MESSAGE-----
diff --git a/personal/q3k/wow/secrets/cipher/oauth-secret b/personal/q3k/wow/secrets/cipher/oauth-secret
new file mode 100644
index 0000000..fedf401
--- /dev/null
+++ b/personal/q3k/wow/secrets/cipher/oauth-secret
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+EWu/MPG8dkc8r8FJyy4oI0we4ec=
+=KoHU
+-----END PGP MESSAGE-----
diff --git a/personal/q3k/wow/secrets/cipher/panel-secret b/personal/q3k/wow/secrets/cipher/panel-secret
new file mode 100644
index 0000000..d43da27
--- /dev/null
+++ b/personal/q3k/wow/secrets/cipher/panel-secret
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+Z39r7G++Es9JHr2AqnoEY3s=
+=E5nj
+-----END PGP MESSAGE-----
diff --git a/personal/q3k/wow/secrets/cipher/soap-password b/personal/q3k/wow/secrets/cipher/soap-password
new file mode 100644
index 0000000..95c6e06
--- /dev/null
+++ b/personal/q3k/wow/secrets/cipher/soap-password
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+eFZDGjE5IA==
+=l4Xr
+-----END PGP MESSAGE-----
diff --git a/personal/q3k/wow/secrets/plain/.gitignore b/personal/q3k/wow/secrets/plain/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/personal/q3k/wow/secrets/plain/.gitignore
@@ -0,0 +1 @@
+*