From 16dc3c86997118c4e8f340ff8dd4c99017003fbe Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 9 Feb 2020 20:32:14 +0200 Subject: [PATCH] Add initial provisioning API --- config/config.go | 5 + example-config.yaml | 7 + go.mod | 6 +- go.sum | 4 + main.go | 10 ++ provisioning.go | 340 ++++++++++++++++++++++++++++++++++++++++++++ user.go | 1 - 7 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 provisioning.go diff --git a/config/config.go b/config/config.go index c5e3b5a..e8a4f0a 100644 --- a/config/config.go +++ b/config/config.go @@ -45,6 +45,11 @@ type Config struct { StateStore string `yaml:"state_store_path,omitempty"` + Provisioning struct { + Prefix string `yaml:"prefix"` + SharedSecret string `yaml:"shared_secret"` + } `yaml:"provisioning"` + ID string `yaml:"id"` Bot struct { Username string `yaml:"username"` diff --git a/example-config.yaml b/example-config.yaml index 98bacc8..645b301 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -27,6 +27,13 @@ appservice: max_open_conns: 20 max_idle_conns: 2 + # Settings for provisioning API + provisioning: + # Prefix for the provisioning API paths. + prefix: /_matrix/provision/v1 + # Shared secret for authentication. If set to "disable", the provisioning API will be disabled. + shared_secret: disable + # The unique ID of this appservice. id: whatsapp # Appservice bot details. diff --git a/go.mod b/go.mod index 5882d92..89e1142 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.11 require ( github.com/Rhymen/go-whatsapp v0.0.2-0.20190524185555-8d76e32a6d8e github.com/chai2010/webp v1.1.0 + github.com/gorilla/mux v1.7.2 + github.com/gorilla/websocket v1.4.0 github.com/lib/pq v1.1.1 github.com/mattn/go-sqlite3 v1.10.0 github.com/pkg/errors v0.8.1 @@ -12,8 +14,8 @@ require ( gopkg.in/yaml.v2 v2.2.2 maunium.net/go/mauflag v1.0.0 maunium.net/go/maulogger/v2 v2.0.0 - maunium.net/go/mautrix v0.1.0-alpha.3.0.20191230181907-055c3acd81cd - maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191230184104-a0aaaf14b728 + maunium.net/go/mautrix v0.1.0-alpha.3.0.20200209182939-9df6760d40d2 + maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20200209183024-a3d12dc80898 ) replace ( diff --git a/go.sum b/go.sum index 4002cff..a95c3cb 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ maunium.net/go/mautrix v0.1.0-alpha.3.0.20190825132810-9d870654e9d2/go.mod h1:O+ maunium.net/go/mautrix v0.1.0-alpha.3.0.20191110191816-178ce1f1561d/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg= maunium.net/go/mautrix v0.1.0-alpha.3.0.20191230181907-055c3acd81cd h1:stoHlgxDA3AKrULnJo1Ubmrb/Yk7iz2ucb0JA3YNMDM= maunium.net/go/mautrix v0.1.0-alpha.3.0.20191230181907-055c3acd81cd/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg= +maunium.net/go/mautrix v0.1.0-alpha.3.0.20200209182939-9df6760d40d2 h1:Ewvf0/4z0OQ7IyySxP5rjhzpL01Slxhup7ugHieAlrI= +maunium.net/go/mautrix v0.1.0-alpha.3.0.20200209182939-9df6760d40d2/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg= maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190618052224-6e6c9bb47548 h1:ni1nqs+2AOO+g1ND6f2W0pMcb6sIDVqzerXosO+pI2g= maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190618052224-6e6c9bb47548/go.mod h1:yVWU0gvIHIXClgyVnShiufiDksFbFrBqHG9lDAYcmGI= maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190822210104-3e49344e186b h1:/03X0PPgtk4pqXcdH86xMzOl891whG5A1hFXQ+xXons= @@ -99,3 +101,5 @@ maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191230181948-bf5d2e16a792 h maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191230181948-bf5d2e16a792/go.mod h1:ek/PBMaq4AxuI5NP+XAq5Ma2u+ZTjUUaUr6dh4w9zSw= maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191230184104-a0aaaf14b728 h1:pJXS+GUB5zClAB8cIJ/bjf9tKFJtjt+VbTfdLZm6qzA= maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191230184104-a0aaaf14b728/go.mod h1:RfFZ/Z6uBSbpUQVkiEhG3P1EBaPK5kc+AGKtuMMbg9k= +maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20200209183024-a3d12dc80898 h1:tWHXTNREuGry2xXS1hGOg+FIUwRxGXie5fymiGD3YoA= +maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20200209183024-a3d12dc80898/go.mod h1:RfFZ/Z6uBSbpUQVkiEhG3P1EBaPK5kc+AGKtuMMbg9k= diff --git a/main.go b/main.go index 60167ec..ca4aa11 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,7 @@ type Bridge struct { DB *database.Database Log log.Logger StateStore *database.SQLStateStore + Provisioning *ProvisioningAPI Bot *appservice.IntentAPI Formatter *Formatter Relaybot *User @@ -208,6 +209,11 @@ func (bridge *Bridge) Init() { bridge.DB.SetMaxOpenConns(bridge.Config.AppService.Database.MaxOpenConns) bridge.DB.SetMaxIdleConns(bridge.Config.AppService.Database.MaxIdleConns) + ss := bridge.Config.AppService.Provisioning.SharedSecret + if len(ss) > 0 && ss != "disable" { + bridge.Provisioning = &ProvisioningAPI{bridge: bridge} + } + bridge.Log.Debugln("Initializing Matrix event processor") bridge.EventProcessor = appservice.NewEventProcessor(bridge.AS) bridge.Log.Debugln("Initializing Matrix event handler") @@ -221,6 +227,10 @@ func (bridge *Bridge) Start() { bridge.Log.Fatalln("Failed to initialize database:", err) os.Exit(15) } + if bridge.Provisioning != nil { + bridge.Log.Debugln("Initializing provisioning API") + bridge.Provisioning.Init() + } bridge.LoadRelaybot() bridge.Log.Debugln("Checking connection to homeserver") bridge.ensureConnection() diff --git a/provisioning.go b/provisioning.go new file mode 100644 index 0000000..4245b03 --- /dev/null +++ b/provisioning.go @@ -0,0 +1,340 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/Rhymen/go-whatsapp" + "github.com/gorilla/websocket" + log "maunium.net/go/maulogger/v2" + + "maunium.net/go/mautrix-whatsapp/types" + whatsappExt "maunium.net/go/mautrix-whatsapp/whatsapp-ext" +) + +type ProvisioningAPI struct { + bridge *Bridge + log log.Logger +} + +func (prov *ProvisioningAPI) Init() { + prov.log = prov.bridge.Log.Sub("Provisioning") + prov.log.Debugln("Enabling provisioning API at", prov.bridge.Config.AppService.Provisioning.Prefix) + r := prov.bridge.AS.Router.PathPrefix(prov.bridge.Config.AppService.Provisioning.Prefix).Subrouter() + r.Use(prov.AuthMiddleware) + r.HandleFunc("/ping", prov.Ping).Methods(http.MethodGet) + r.HandleFunc("/login", prov.Login) + r.HandleFunc("/logout", prov.Logout).Methods(http.MethodPost) + r.HandleFunc("/delete_session", prov.DeleteSession).Methods(http.MethodPost) + r.HandleFunc("/delete_connection", prov.DeleteConnection).Methods(http.MethodPost) + r.HandleFunc("/disconnect", prov.Disconnect).Methods(http.MethodPost) + r.HandleFunc("/reconnect", prov.Reconnect).Methods(http.MethodPost) +} + +func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + auth = auth[len("Bearer "):] + if auth != prov.bridge.Config.AppService.Provisioning.SharedSecret { + jsonResponse(w, http.StatusForbidden, map[string]interface{}{ + "error": "Invalid auth token", + "errcode": "M_FORBIDDEN", + }) + return + } + userID := r.URL.Query().Get("user_id") + user := prov.bridge.GetUserByMXID(types.MatrixUserID(userID)) + h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", user))) + }) +} + +type Error struct { + Success bool `json:"success"` + Error string `json:"error"` + ErrCode string `json:"errcode"` +} + +type Response struct { + Success bool `json:"success"` + Status string `json:"status"` +} + +func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if user.Session == nil && user.Conn == nil { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "Nothing to purge: no session information stored and no active connection.", + ErrCode: "no session", + }) + return + } + user.SetSession(nil) + if user.Conn != nil { + _, _ = user.Conn.Disconnect() + user.Conn.RemoveHandlers() + user.Conn = nil + } + jsonResponse(w, http.StatusOK, Response{true, "Session information purged"}) +} + +func (prov *ProvisioningAPI) DeleteConnection(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if user.Conn == nil { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "You don't have a WhatsApp connection.", + ErrCode: "not connected", + }) + return + } + sess, err := user.Conn.Disconnect() + if err == nil && len(sess.Wid) > 0 { + user.SetSession(&sess) + } + user.Conn.RemoveHandlers() + user.Conn = nil + jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp and connection deleted"}) +} + +func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if user.Conn == nil { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "You don't have a WhatsApp connection.", + ErrCode: "no connection", + }) + return + } + sess, err := user.Conn.Disconnect() + if err == whatsapp.ErrNotConnected { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "You were not connected", + ErrCode: "not connected", + }) + return + } else if err != nil { + user.log.Warnln("Error while disconnecting:", err) + jsonResponse(w, http.StatusInternalServerError, Error{ + Error: fmt.Sprintf("Unknown error while disconnecting: %v", err), + ErrCode: err.Error(), + }) + return + } else if len(sess.Wid) > 0 { + user.SetSession(&sess) + } + jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"}) +} + +func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if user.Conn == nil { + if user.Session == nil { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "No existing connection and no session. Please log in first.", + ErrCode: "no session", + }) + } else { + user.Connect(false) + jsonResponse(w, http.StatusOK, Response{true, "Created connection to WhatsApp."}) + } + return + } + err := user.Conn.Restore() + if err == whatsapp.ErrInvalidSession { + if user.Session != nil { + user.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") + var sess whatsapp.Session + sess, err = user.Conn.RestoreWithSession(*user.Session) + if err == nil { + user.SetSession(&sess) + } + } else { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "You're not logged in", + ErrCode: "not logged in", + }) + return + } + } else if err == whatsapp.ErrLoginInProgress { + jsonResponse(w, http.StatusConflict, Error{ + Error: "A login or reconnection is already in progress.", + ErrCode: "login in progress", + }) + return + } + if err != nil { + user.log.Warnln("Error while reconnecting:", err) + if err == whatsapp.ErrAlreadyLoggedIn { + jsonResponse(w, http.StatusConflict, Error{ + Error: "You were already connected.", + ErrCode: err.Error(), + }) + } else if err.Error() == "restore session connection timed out" { + jsonResponse(w, http.StatusForbidden, Error{ + Error: "Reconnection timed out. Is WhatsApp on your phone reachable?", + ErrCode: err.Error(), + }) + } else { + jsonResponse(w, http.StatusForbidden, Error{ + Error: fmt.Sprintf("Unknown error while reconnecting: %v", err), + ErrCode: err.Error(), + }) + } + user.log.Debugln("Disconnecting due to failed session restore in reconnect command...") + sess, err := user.Conn.Disconnect() + if err != nil { + user.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) + } else if len(sess.Wid) > 0 { + user.SetSession(&sess) + } + return + } + user.ConnectionErrors = 0 + user.PostLogin() + jsonResponse(w, http.StatusOK, Response{true, "Reconnected successfully."}) +} + +func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + wa := map[string]interface{}{ + "has_session": user.Session != nil, + "management_room": user.ManagementRoom, + "conn": nil, + "ping": nil, + } + if user.Conn != nil { + wa["conn"] = map[string]interface{}{ + "is_connected": user.Conn.IsConnected(), + "is_logged_in": user.Conn.IsLoggedIn(), + "is_login_in_progress": user.Conn.IsLoginInProgress(), + } + ok, err := user.Conn.AdminTest() + wa["ping"] = map[string]interface{}{ + "ok": ok, + "err": err, + } + } + resp := map[string]interface{}{ + "mxid": user.MXID, + "admin": user.Admin, + "whitelisted": user.Whitelisted, + "relaybot_whitelisted": user.RelaybotWhitelisted, + "whatsapp": wa, + } + jsonResponse(w, http.StatusOK, resp) +} + +func jsonResponse(w http.ResponseWriter, status int, response interface{}) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(response) +} + +func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + if user.Session == nil { + jsonResponse(w, http.StatusNotFound, Error{ + Error: "You're not logged in", + ErrCode: "not logged in", + }) + return + } + + err := user.Conn.Logout() + if err != nil { + user.log.Warnln("Error while logging out:", err) + jsonResponse(w, http.StatusInternalServerError, Error{ + Error: fmt.Sprintf("Unknown error while logging out: %v", err), + ErrCode: err.Error(), + }) + return + } + _, err = user.Conn.Disconnect() + if err != nil { + user.log.Warnln("Error while disconnecting after logout:", err) + } + user.Conn.RemoveHandlers() + user.Conn = nil + user.SetSession(nil) + jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."}) +} + +var upgrader = websocket.Upgrader{} + +func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("user_id") + user := prov.bridge.GetUserByMXID(types.MatrixUserID(userID)) + + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + prov.log.Errorfln("Failed to upgrade connection to websocket:", err) + return + } + defer c.Close() + + if !user.Connect(true) { + user.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") + _ = c.WriteJSON(Error{ + Error: "Failed to connect to WhatsApp", + ErrCode: "connection error", + }) + return + } + + qrChan := make(chan string, 3) + go func() { + for code := range qrChan { + if code == "stop" { + return + } + _ = c.WriteJSON(map[string]interface{}{ + "code": code, + }) + } + }() + session, err := user.Conn.LoginWithRetry(qrChan, 0) //user.bridge.Config.Bridge.LoginQRRegenCount) + qrChan <- "stop" + if err != nil { + var msg string + if err == whatsapp.ErrAlreadyLoggedIn { + msg = "You're already logged in" + } else if err == whatsapp.ErrLoginInProgress { + msg = "You have a login in progress already." + } else if err == whatsapp.ErrLoginTimedOut { + msg = "QR code scan timed out. Please try again." + } else { + user.log.Warnln("Failed to log in:", err) + msg = fmt.Sprintf("Unknown error while logging in: %v", err) + } + _ = c.WriteJSON(Error{ + Error: msg, + ErrCode: err.Error(), + }) + return + } + user.ConnectionErrors = 0 + user.JID = strings.Replace(user.Conn.Info.Wid, whatsappExt.OldUserSuffix, whatsappExt.NewUserSuffix, 1) + user.SetSession(&session) + _ = c.WriteJSON(map[string]interface{}{ + "success": true, + "jid": user.JID, + }) + user.PostLogin() +} diff --git a/user.go b/user.go index b6faa0f..d2d3c08 100644 --- a/user.go +++ b/user.go @@ -246,7 +246,6 @@ func (user *User) IsLoginInProgress() bool { func (user *User) loginQrChannel(ce *CommandEvent, qrChan <-chan string, eventIDChan chan<- string) { var qrEventID string for code := range qrChan { - fmt.Println("qrChan:", code) if code == "stop" { return }