wow: init
This is a shitty MMORPG server. Private. Do not touch.
Change-Id: Iddfce069f5895632d305a73fcaa2d963e25dc600
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
+}