diff --git a/client/comms/wsconn.go b/client/comms/wsconn.go index 04d8071b65..5b4984730a 100644 --- a/client/comms/wsconn.go +++ b/client/comms/wsconn.go @@ -41,6 +41,15 @@ const ( DefaultResponseTimeout = 30 * time.Second ) +// ConnectionStatus represents the current status of the websocket connection. +type ConnectionStatus uint32 + +const ( + Disconnected ConnectionStatus = iota + Connected + InvalidCert +) + // ErrInvalidCert is the error returned when attempting to use an invalid cert // to set up a ws connection. var ErrInvalidCert = fmt.Errorf("invalid certificate") @@ -88,7 +97,7 @@ type WsCfg struct { // // NOTE: Disconnect event notifications may lag behind actual // disconnections. - ConnectEventFunc func(bool) + ConnectEventFunc func(ConnectionStatus) // Logger is the logger for the WsConn. Logger dex.Logger @@ -112,8 +121,7 @@ type wsConn struct { wsMtx sync.Mutex ws *websocket.Conn - connectedMtx sync.RWMutex - connected bool + connectionStatus uint32 // atomic reqMtx sync.RWMutex respHandlers map[uint64]*responseHandler @@ -163,20 +171,16 @@ func NewWsConn(cfg *WsCfg) (WsConn, error) { // IsDown indicates if the connection is known to be down. func (conn *wsConn) IsDown() bool { - conn.connectedMtx.RLock() - defer conn.connectedMtx.RUnlock() - return !conn.connected + return atomic.LoadUint32(&conn.connectionStatus) != uint32(Connected) } -// setConnected updates the connection's connected state and runs the +// setConnectionStatus updates the connection's status and runs the // ConnectEventFunc in case of a change. -func (conn *wsConn) setConnected(connected bool) { - conn.connectedMtx.Lock() - statusChange := conn.connected != connected - conn.connected = connected - conn.connectedMtx.Unlock() +func (conn *wsConn) setConnectionStatus(status ConnectionStatus) { + oldStatus := atomic.SwapUint32(&conn.connectionStatus, uint32(status)) + statusChange := oldStatus != uint32(status) if statusChange && conn.cfg.ConnectEventFunc != nil { - conn.cfg.ConnectEventFunc(connected) + conn.cfg.ConnectEventFunc(status) } } @@ -195,11 +199,13 @@ func (conn *wsConn) connect(ctx context.Context) error { if err != nil { var e x509.UnknownAuthorityError if errors.As(err, &e) { + conn.setConnectionStatus(InvalidCert) if conn.tlsCfg == nil { return ErrCertRequired } return ErrInvalidCert } + conn.setConnectionStatus(Disconnected) return err } @@ -241,7 +247,7 @@ func (conn *wsConn) connect(ctx context.Context) error { conn.ws = ws conn.wsMtx.Unlock() - conn.setConnected(true) + conn.setConnectionStatus(Connected) conn.wg.Add(1) go func() { defer conn.wg.Done() @@ -264,7 +270,7 @@ func (conn *wsConn) close() { // run as a goroutine. Increment the wg before calling read. func (conn *wsConn) read(ctx context.Context) { reconnect := func() { - conn.setConnected(false) + conn.setConnectionStatus(Disconnected) conn.reconnectCh <- struct{}{} } @@ -406,17 +412,34 @@ func (conn *wsConn) Connect(ctx context.Context) (*sync.WaitGroup, error) { var ctxInternal context.Context ctxInternal, conn.cancel = context.WithCancel(ctx) - conn.wg.Add(1) + err := conn.connect(ctxInternal) + if err != nil { + // If the certificate is invalid or missing, do not start the reconnect + // loop, and return an error with no WaitGroup. + if errors.Is(err, ErrInvalidCert) || errors.Is(err, ErrCertRequired) { + conn.cancel() + conn.wg.Wait() // probably a no-op + close(conn.readCh) + return nil, err + } + + // The read loop would normally trigger keepAlive, but it wasn't started + // on account of a connect error. + conn.log.Errorf("Initial connection failed, starting reconnect loop: %v", err) + time.AfterFunc(5*time.Second, func() { + conn.reconnectCh <- struct{}{} + }) + } + + conn.wg.Add(2) go func() { defer conn.wg.Done() conn.keepAlive(ctxInternal) }() - - conn.wg.Add(1) go func() { defer conn.wg.Done() <-ctxInternal.Done() - conn.setConnected(false) + conn.setConnectionStatus(Disconnected) conn.wsMtx.Lock() if conn.ws != nil { conn.log.Debug("Sending close 1000 (normal) message.") @@ -427,16 +450,6 @@ func (conn *wsConn) Connect(ctx context.Context) (*sync.WaitGroup, error) { close(conn.readCh) // signal to MessageSource receivers that the wsConn is dead }() - err := conn.connect(ctxInternal) - if err != nil { - // The read loop would normally trigger keepAlive, but it wasn't started - // on account of a connect error. - conn.log.Errorf("Initial connection failed, starting reconnect loop: %v", err) - time.AfterFunc(5*time.Second, func() { - conn.reconnectCh <- struct{}{} - }) - } - return &conn.wg, err } diff --git a/client/core/account.go b/client/core/account.go index 0d76e31233..3bf25050c6 100644 --- a/client/core/account.go +++ b/client/core/account.go @@ -2,6 +2,7 @@ package core import ( "encoding/hex" + "errors" "fmt" "decred.org/dcrdex/client/db" @@ -190,3 +191,26 @@ func (c *Core) AccountImport(pw []byte, acct Account) error { return nil } + +// UpdateCert attempts to connect to a server using a new TLS certificate. If +// the connection is successful, then the cert in the database is updated. +func (c *Core) UpdateCert(host string, cert []byte) error { + accountInfo, err := c.db.Account(host) + if err != nil { + return err + } + + accountInfo.Cert = cert + + _, connected := c.connectAccount(accountInfo) + if !connected { + return errors.New("failed to connect using new cert") + } + + err = c.db.UpdateAccountInfo(accountInfo) + if err != nil { + return fmt.Errorf("failed to update account info: %w", err) + } + + return nil +} diff --git a/client/core/account_test.go b/client/core/account_test.go index 9e36985ac1..affe0143d9 100644 --- a/client/core/account_test.go +++ b/client/core/account_test.go @@ -9,6 +9,7 @@ import ( "testing" "decred.org/dcrdex/client/db" + "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -179,6 +180,82 @@ func TestAccountDisable(t *testing.T) { } } +func TestUpdateCert(t *testing.T) { + rig := newTestRig() + tCore := rig.core + rig.db.acct.Paid = true + rig.db.acct.FeeCoin = encode.RandomBytes(32) + + tests := []struct { + name string + host string + acctErr bool + updateAccountInfoErr bool + queueConfig bool + expectError bool + }{ + { + name: "ok", + host: rig.db.acct.Host, + queueConfig: true, + }, + { + name: "connect error", + host: rig.db.acct.Host, + queueConfig: false, + expectError: true, + }, + { + name: "db get account error", + host: rig.db.acct.Host, + queueConfig: true, + acctErr: true, + expectError: true, + }, + { + name: "db update account err", + host: rig.db.acct.Host, + queueConfig: true, + updateAccountInfoErr: true, + expectError: true, + }, + } + + for _, test := range tests { + rig.db.verifyUpdateAccountInfo = false + if test.updateAccountInfoErr { + rig.db.updateAccountInfoErr = errors.New("") + } else { + rig.db.updateAccountInfoErr = nil + } + if test.acctErr { + rig.db.acctErr = errors.New("") + } else { + rig.db.acctErr = nil + } + randomCert := encode.RandomBytes(32) + if test.queueConfig { + rig.queueConfig() + } + err := tCore.UpdateCert(test.host, randomCert) + if test.expectError { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if !rig.db.verifyUpdateAccountInfo { + t.Fatalf("%s: expected update account to be called but it was not", test.name) + } + if !bytes.Equal(randomCert, rig.db.acct.Cert) { + t.Fatalf("%s: expected account to be updated with cert but it was not", test.name) + } + } +} + func TestAccountExportPasswordError(t *testing.T) { rig := newTestRig() tCore := rig.core diff --git a/client/core/core.go b/client/core/core.go index 529b9cd06c..e9f1c1235d 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -134,8 +134,9 @@ type dexConnection struct { epochMtx sync.RWMutex epoch map[string]uint64 - // connected is a best guess on the ws connection status. - connected uint32 + + // connectionStatus is a best guess on the ws connection status. + connectionStatus uint32 pendingFeeMtx sync.RWMutex pendingFee *pendingFeeState @@ -164,6 +165,11 @@ func (dc *dexConnection) running(mkt string) bool { return mktCfg.Running() } +// status returns the status of the connection to the dex. +func (dc *dexConnection) status() comms.ConnectionStatus { + return comms.ConnectionStatus(atomic.LoadUint32(&dc.connectionStatus)) +} + func (dc *dexConnection) feeAsset(assetID uint32) *msgjson.FeeAsset { dc.cfgMtx.RLock() defer dc.cfgMtx.RUnlock() @@ -296,10 +302,10 @@ func (dc *dexConnection) exchangeInfo() *Exchange { dc.cfgMtx.RUnlock() if cfg == nil { // no config, assets, or markets data return &Exchange{ - Host: dc.acct.host, - AcctID: acctID, - Connected: atomic.LoadUint32(&dc.connected) == 1, - PendingFee: dc.getPendingFee(), + Host: dc.acct.host, + AcctID: acctID, + ConnectionStatus: dc.status(), + PendingFee: dc.getPendingFee(), } } @@ -329,15 +335,15 @@ func (dc *dexConnection) exchangeInfo() *Exchange { } return &Exchange{ - Host: dc.acct.host, - AcctID: acctID, - Markets: dc.marketMap(), - Assets: assets, - Connected: atomic.LoadUint32(&dc.connected) == 1, - Fee: dcrAsset, - RegFees: feeAssets, - PendingFee: dc.getPendingFee(), - CandleDurs: cfg.BinSizes, + Host: dc.acct.host, + AcctID: acctID, + Markets: dc.marketMap(), + Assets: assets, + ConnectionStatus: dc.status(), + Fee: dcrAsset, + RegFees: feeAssets, + PendingFee: dc.getPendingFee(), + CandleDurs: cfg.BinSizes, } } @@ -947,11 +953,10 @@ func (c *Core) dex(addr string) (*dexConnection, bool, error) { c.connMtx.RLock() dc, found := c.conns[host] c.connMtx.RUnlock() - connected := found && atomic.LoadUint32(&dc.connected) == 1 if !found { return nil, false, fmt.Errorf("unknown DEX %s", addr) } - return dc, connected, nil + return dc, dc.status() == comms.Connected, nil } // Get the *dexConnection for the the host. Return an error if the DEX is not @@ -1395,7 +1400,22 @@ func (c *Core) Network() dex.Network { // Exchanges creates a map of *Exchange keyed by host, including markets and // orders. func (c *Core) Exchanges() map[string]*Exchange { - return c.exchangeMap() + dcs := c.dexConnections() + infos := make(map[string]*Exchange, len(dcs)) + for _, dc := range dcs { + infos[dc.acct.host] = dc.exchangeInfo() + } + return infos +} + +// Exchange returns an exchange with a certain host. It returns an error if +// no exchange exists at that host. +func (c *Core) Exchange(host string) (*Exchange, error) { + dc, _, err := c.dex(host) + if err != nil { + return nil, err + } + return dc.exchangeInfo(), nil } // dexConnections creates a slice of the *dexConnection in c.conns. @@ -1409,17 +1429,6 @@ func (c *Core) dexConnections() []*dexConnection { return conns } -// exchangeMap creates a map of *Exchange keyed by host, including markets and -// orders. -func (c *Core) exchangeMap() map[string]*Exchange { - dcs := c.dexConnections() - infos := make(map[string]*Exchange, len(dcs)) - for _, dc := range dcs { - infos[dc.acct.host] = dc.exchangeInfo() - } - return infos -} - // wallet gets the wallet for the specified asset ID in a thread-safe way. func (c *Core) wallet(assetID uint32) (*xcWallet, bool) { c.walletMtx.RLock() @@ -1693,7 +1702,7 @@ func (c *Core) assetMap() map[uint32]*SupportedAsset { func (c *Core) User() *User { return &User{ Assets: c.assetMap(), - Exchanges: c.exchangeMap(), + Exchanges: c.Exchanges(), Initialized: c.IsInitialized(), SeedGenerationTime: c.seedGenerationTime, } @@ -4943,7 +4952,8 @@ func (c *Core) initialize() { // connectAccount makes a connection to the DEX for the given account. If a // non-nil dexConnection is returned, it was inserted into the conns map even if // the initial connection attempt failed (connected == false), and the connect -// retry / keepalive loop is active. +// retry / keepalive loop is active. If there was already a dexConnection, it is +// first stopped. func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connected bool) { if !acct.Paid && len(acct.FeeCoin) == 0 { // Register should have set this when creating the account that was @@ -4958,6 +4968,23 @@ func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connecte c.log.Errorf("skipping loading of %s due to address parse error: %v", host, err) return } + + c.connMtx.RLock() + if dc := c.conns[host]; dc != nil { + dc.connMaster.Disconnect() + dc.acct.lock() + dc.booksMtx.Lock() + for m, b := range dc.books { + b.closeFeeds() + if b.closeTimer != nil { + b.closeTimer.Stop() + } + delete(dc.books, m) + } + dc.booksMtx.Unlock() + } // leave it in the map so it remains listed if connectDEX fails + c.connMtx.RUnlock() + dc, err = c.connectDEX(acct) if dc == nil { c.log.Errorf("Cannot connect to DEX %s: %v", host, err) @@ -5697,6 +5724,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn apiVer: -1, reportingConnects: reporting, spots: make(map[string]*msgjson.Spot), + connectionStatus: uint32(comms.Disconnected), // On connect, must set: cfg, epoch, and assets. } @@ -5726,8 +5754,8 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn return nil, errors.New("a TLS connection is required when not using a hidden service") } - wsCfg.ConnectEventFunc = func(connected bool) { - c.handleConnectEvent(dc, connected) + wsCfg.ConnectEventFunc = func(status comms.ConnectionStatus) { + c.handleConnectEvent(dc, status) } wsCfg.ReconnectSync = func() { go c.handleReconnect(host) @@ -5742,6 +5770,14 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn dc.WsConn = conn dc.connMaster = dex.NewConnectionMaster(conn) + // At this point, we have a valid dexConnection object whether or not we can + // actually connect. In any return below, we return the dexConnection so it + // may be tracked in the c.conns map (and listed as a known DEX). TODO: + // split the above code into a dexConnection constructor, and the below into + // a startDexConnection function so we don't have the anti-pattern of + // returning a non-nil object with a non-nil error and requiring the caller + // to check both! + // Start listening for messages. The listener stops when core shuts down or // the dexConnection's ConnectionMaster is shut down. This goroutine should // be started as long as the reconnect loop is running. It only returns when @@ -5881,11 +5917,9 @@ func (dc *dexConnection) broadcastingConnect() bool { // lost or established. // // NOTE: Disconnect event notifications may lag behind actual disconnections. -func (c *Core) handleConnectEvent(dc *dexConnection, connected bool) { - var v uint32 +func (c *Core) handleConnectEvent(dc *dexConnection, status comms.ConnectionStatus) { topic := TopicDEXDisconnected - if connected { - v = 1 + if status == comms.Connected { topic = TopicDEXConnected } else { for _, tracker := range dc.trackedTrades() { @@ -5901,10 +5935,10 @@ func (c *Core) handleConnectEvent(dc *dexConnection, connected bool) { tracker.mtx.Unlock() } } - atomic.StoreUint32(&dc.connected, v) + atomic.StoreUint32(&dc.connectionStatus, uint32(status)) if dc.broadcastingConnect() { subject, details := c.formatDetails(topic, dc.acct.host) - dc.notify(newConnEventNote(topic, subject, dc.acct.host, connected, details, db.Poke)) + dc.notify(newConnEventNote(topic, subject, dc.acct.host, status, details, db.Poke)) } } diff --git a/client/core/core_test.go b/client/core/core_test.go index 632ca7b7ef..698231da46 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -269,7 +269,7 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection, trades: make(map[order.OrderID]*trackedTrade), epoch: map[string]uint64{tDcrBtcMktName: 0}, apiVer: serverdex.PreAPIVersion, - connected: 1, + connectionStatus: uint32(comms.Connected), reportingConnects: 1, spots: make(map[string]*msgjson.Spot), }, conn, acct @@ -360,7 +360,7 @@ type TDB struct { accountProofErr error verifyAccountPaid bool verifyCreateAccount bool - verifyUpdateAccount bool + verifyUpdateAccountInfo bool accountProofPersisted *db.AccountProof disabledAcct *db.AccountInfo disableAccountErr error @@ -370,6 +370,7 @@ type TDB struct { recryptErr error deleteInactiveOrdersErr error deleteInactiveMatchesErr error + updateAccountInfoErr error } func (tdb *TDB) Run(context.Context) {} @@ -398,10 +399,10 @@ func (tdb *TDB) DisableAccount(url string) error { return tdb.disableAccountErr } -func (tdb *TDB) UpdateAccount(ai *db.AccountInfo) error { - tdb.verifyUpdateAccount = true +func (tdb *TDB) UpdateAccountInfo(ai *db.AccountInfo) error { + tdb.verifyUpdateAccountInfo = true tdb.acct = ai - return nil + return tdb.updateAccountInfoErr } func (tdb *TDB) UpdateOrder(m *db.MetaOrder) error { @@ -2753,12 +2754,12 @@ wait: rig.dc.acct.unlock(rig.crypter) // DEX not connected - atomic.StoreUint32(&rig.dc.connected, 0) + atomic.StoreUint32(&rig.dc.connectionStatus, uint32(comms.Disconnected)) _, err = tCore.Trade(tPW, form) if err == nil { t.Fatalf("no error for disconnected dex") } - atomic.StoreUint32(&rig.dc.connected, 1) + atomic.StoreUint32(&rig.dc.connectionStatus, uint32(comms.Connected)) // No base asset form.Base = 12345 diff --git a/client/core/notification.go b/client/core/notification.go index 9b6be2e0f2..62936fd9e4 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -6,6 +6,7 @@ package core import ( "fmt" + "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/msgjson" @@ -335,8 +336,8 @@ func (on *EpochNotification) String() string { // ConnEventNote is a notification regarding individual DEX connection status. type ConnEventNote struct { db.Notification - Host string `json:"host"` - Connected bool `json:"connected"` + Host string `json:"host"` + ConnectionStatus comms.ConnectionStatus `json:"connectionStatus"` } const ( @@ -344,11 +345,11 @@ const ( TopicDEXDisconnected Topic = "DEXDisconnected" ) -func newConnEventNote(topic Topic, subject, host string, connected bool, details string, severity db.Severity) *ConnEventNote { +func newConnEventNote(topic Topic, subject, host string, status comms.ConnectionStatus, details string, severity db.Severity) *ConnEventNote { return &ConnEventNote{ - Notification: db.NewNotification(NoteTypeConnEvent, topic, subject, details, severity), - Host: host, - Connected: connected, + Notification: db.NewNotification(NoteTypeConnEvent, topic, subject, details, severity), + Host: host, + ConnectionStatus: status, } } diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go index d124444a87..59c484b955 100644 --- a/client/core/trade_simnet_test.go +++ b/client/core/trade_simnet_test.go @@ -45,6 +45,7 @@ import ( "decred.org/dcrdex/client/asset/doge" "decred.org/dcrdex/client/asset/eth" "decred.org/dcrdex/client/asset/ltc" + "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" @@ -727,7 +728,7 @@ func TestOrderStatusReconciliation(t *testing.T) { disconnectTimeout := 10 * sleepFactor * time.Second disconnected := client2.notes.find(context.Background(), disconnectTimeout, func(n Notification) bool { connNote, ok := n.(*ConnEventNote) - return ok && connNote.Host == dexHost && !connNote.Connected + return ok && connNote.Host == dexHost && connNote.ConnectionStatus != comms.Connected }) if !disconnected { t.Fatalf("client 2 dex not disconnected after %v", disconnectTimeout) diff --git a/client/core/types.go b/client/core/types.go index a8d6c6c3da..20910f624c 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -11,6 +11,7 @@ import ( "sync" "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" @@ -498,15 +499,15 @@ type PendingFeeState struct { // Exchange represents a single DEX with any number of markets. type Exchange struct { - Host string `json:"host"` - AcctID string `json:"acctID"` - Markets map[string]*Market `json:"markets"` - Assets map[uint32]*dex.Asset `json:"assets"` - Connected bool `json:"connected"` - Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR. - RegFees map[string]*FeeAsset `json:"regFees"` - PendingFee *PendingFeeState `json:"pendingFee,omitempty"` - CandleDurs []string `json:"candleDurs"` + Host string `json:"host"` + AcctID string `json:"acctID"` + Markets map[string]*Market `json:"markets"` + Assets map[uint32]*dex.Asset `json:"assets"` + ConnectionStatus comms.ConnectionStatus `json:"connectionStatus"` + Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR. + RegFees map[string]*FeeAsset `json:"regFees"` + PendingFee *PendingFeeState `json:"pendingFee,omitempty"` + CandleDurs []string `json:"candleDurs"` } // newDisplayID creates a display-friendly market ID for a base/quote ID pair. diff --git a/client/db/bolt/db.go b/client/db/bolt/db.go index cfe5a44534..258578fd4f 100644 --- a/client/db/bolt/db.go +++ b/client/db/bolt/db.go @@ -522,6 +522,19 @@ func (db *BoltDB) CreateAccount(ai *dexdb.AccountInfo) error { }) } +// UpdateAccountInfo updates the account info for an existing account with +// the same Host as the parameter. If no account exists with this host, +// an error is returned. +func (db *BoltDB) UpdateAccountInfo(ai *dexdb.AccountInfo) error { + return db.acctsUpdate(func(accts *bbolt.Bucket) error { + acct := accts.Bucket([]byte(ai.Host)) + if acct == nil { + return fmt.Errorf("account not found for %s", ai.Host) + } + return acct.Put(accountKey, ai.Encode()) + }) +} + // deleteAccount removes the account by host. func (db *BoltDB) deleteAccount(host string) error { acctKey := []byte(host) diff --git a/client/db/interface.go b/client/db/interface.go index 05de76c931..c02dc989fb 100644 --- a/client/db/interface.go +++ b/client/db/interface.go @@ -34,6 +34,10 @@ type DB interface { Account(host string) (*AccountInfo, error) // CreateAccount saves the AccountInfo. CreateAccount(ai *AccountInfo) error + // UpdateAccountInfo updates the account info for an existing account with + // the same Host as the parameter. If no account exists with this host, + // an error is returned. + UpdateAccountInfo(ai *AccountInfo) error // DisableAccount sets the AccountInfo disabled status to true. DisableAccount(host string) error // AccountProof retrieves the AccountPoof value specified by url. diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index d393895e14..a8c8f745a8 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -84,6 +84,13 @@ func (c *TCore) CloseWallet(assetID uint32) error { return c.closeWalletErr } func (c *TCore) Exchanges() (exchanges map[string]*core.Exchange) { return c.exchanges } +func (c *TCore) Exchange(host string) (*core.Exchange, error) { + exchange, ok := c.exchanges[host] + if !ok { + return nil, fmt.Errorf("no exchange at %v", host) + } + return exchange, nil +} func (c *TCore) InitializeClient(pw, seed []byte) error { return c.initializeClientErr } diff --git a/client/webserver/api.go b/client/webserver/api.go index 44e04df521..a830bccd1a 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -400,6 +400,24 @@ func (s *WebServer) apiAccountImport(w http.ResponseWriter, r *http.Request) { writeJSON(w, simpleAck(), s.indent) } +func (s *WebServer) apiUpdateCert(w http.ResponseWriter, r *http.Request) { + form := &struct { + Host string `json:"host"` + Cert string `json:"cert"` + }{} + if !readPost(w, r, form) { + return + } + + err := s.core.UpdateCert(form.Host, []byte(form.Cert)) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error updating cert: %w", err)) + return + } + + writeJSON(w, simpleAck(), s.indent) +} + // apiAccountDisable is the handler for the '/disableaccount' API request. func (s *WebServer) apiAccountDisable(w http.ResponseWriter, r *http.Request) { form := new(accountDisableForm) diff --git a/client/webserver/http.go b/client/webserver/http.go index 7db5045930..6a9cd4a5e9 100644 --- a/client/webserver/http.go +++ b/client/webserver/http.go @@ -244,6 +244,33 @@ func (s *WebServer) handleSettings(w http.ResponseWriter, r *http.Request) { s.sendTemplate(w, "settings", data) } +// handleDexSettings is the handler for the '/dexsettings' page request. +func (s *WebServer) handleDexSettings(w http.ResponseWriter, r *http.Request) { + host, err := getHostCtx(r) + if err != nil { + log.Errorf("error getting host ctx: %v", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + exchange, err := s.core.Exchange(host) + if err != nil { + log.Errorf("error getting exchange: %v", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + data := &struct { + CommonArguments + Exchange *core.Exchange + }{ + CommonArguments: *commonArgs(r, fmt.Sprintf("%v Settings | Decred DEX", host)), + Exchange: exchange, + } + + s.sendTemplate(w, "dexsettings", data) +} + type ordersTmplData struct { CommonArguments Assets map[uint32]*core.SupportedAsset diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index a48a17763b..5208c5e982 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -25,6 +25,7 @@ import ( "decred.org/dcrdex/client/asset/btc" "decred.org/dcrdex/client/asset/dcr" "decred.org/dcrdex/client/asset/ltc" + "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" @@ -303,7 +304,7 @@ var tExchanges = map[string]*core.Exchange{ mkid(22, 42): mkMrkt("mona", "dcr"), mkid(28, 0): mkMrkt("vtc", "btc"), }, - Connected: true, + ConnectionStatus: comms.Connected, RegFees: map[string]*core.FeeAsset{ "dcr": { ID: 42, @@ -352,7 +353,7 @@ var tExchanges = map[string]*core.Exchange{ mkid(0, 2): mkMrkt("btc", "ltc"), mkid(22, 141): mkMrkt("mona", "kmd"), }, - Connected: true, + ConnectionStatus: comms.Connected, RegFees: map[string]*core.FeeAsset{ "dcr": { ID: 42, @@ -494,6 +495,14 @@ func (c *TCore) Network() dex.Network { return dex.Mainnet } func (c *TCore) Exchanges() map[string]*core.Exchange { return tExchanges } +func (c *TCore) Exchange(host string) (*core.Exchange, error) { + exchange, ok := tExchanges[host] + if !ok { + return nil, fmt.Errorf("no exchange at %v", host) + } + return exchange, nil +} + func (c *TCore) InitializeClient(pw, seed []byte) error { randomDelay() c.inited = true @@ -1597,6 +1606,9 @@ func (c *TCore) WalletLogFilePath(uint32) (string, error) { func (c *TCore) RecoverWallet(uint32, []byte, bool) error { return nil } +func (c *TCore) UpdateCert(string, []byte) error { + return nil +} func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 90b9a09ba3..9316c90d8a 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -233,4 +233,7 @@ var EnUS = map[string]string{ "confirm_force_message": "This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary!", "confirm": "Confirm", "cancel": "Cancel", + "Update TLS Certificate": "Update TLS Certificate", + "registered dexes": "Registered Dexes:", + "successful_cert_update": "Successfully updated certificate!", } diff --git a/client/webserver/middleware.go b/client/webserver/middleware.go index f3e39fa494..43065ebbfe 100644 --- a/client/webserver/middleware.go +++ b/client/webserver/middleware.go @@ -19,6 +19,7 @@ type ctxID int const ( ctxOID ctxID = iota + ctxHost ) // securityMiddleware adds security headers to the server responses. @@ -133,7 +134,29 @@ func (s *WebServer) requireDEXConnection(next http.Handler) http.Handler { }) } -// orderIDCtx embeds order ID into the request context +// dexHostCtx embeds the host into the request context. +func dexHostCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := chi.URLParam(r, "host") + ctx := context.WithValue(r.Context(), ctxHost, host) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// getHostCtx interprets the context value at ctxHost as a string host. +func getHostCtx(r *http.Request) (string, error) { + untypedHost := r.Context().Value(ctxHost) + if untypedHost == nil { + return "", errors.New("host not set in request") + } + host, ok := untypedHost.(string) + if !ok { + return "", errors.New("type assertion failed") + } + return host, nil +} + +// orderIDCtx embeds order ID into the request context. func orderIDCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { oid := chi.URLParam(r, "oid") diff --git a/client/webserver/site/src/css/application.scss b/client/webserver/site/src/css/application.scss index 0f2265371e..48f3cabf6a 100644 --- a/client/webserver/site/src/css/application.scss +++ b/client/webserver/site/src/css/application.scss @@ -78,3 +78,4 @@ $grid-breakpoints: ( @import "./settings.scss"; @import "./forms.scss"; @import "./forms_dark.scss"; +@import "./dex_settings.scss"; diff --git a/client/webserver/site/src/css/dex_settings.scss b/client/webserver/site/src/css/dex_settings.scss new file mode 100644 index 0000000000..40c211d904 --- /dev/null +++ b/client/webserver/site/src/css/dex_settings.scss @@ -0,0 +1,11 @@ +.connection { + color: green; +} + +.update-cert-msg { + color: green; +} + +.disconnected { + color: red; +} diff --git a/client/webserver/site/src/css/icons.scss b/client/webserver/site/src/css/icons.scss index 65d7e86436..28105059ee 100644 --- a/client/webserver/site/src/css/icons.scss +++ b/client/webserver/site/src/css/icons.scss @@ -56,10 +56,6 @@ content: "\e902"; } -.ico-rocket::before { - content: "\e9a5"; -} - .ico-profile::before { content: "\e903"; } @@ -115,3 +111,11 @@ .ico-checkbox::before { content: "\e90d"; } + +.ico-connection::before { + content: "\e90e"; +} + +.ico-rocket::before { + content: "\e90f"; +} diff --git a/client/webserver/site/src/css/settings.scss b/client/webserver/site/src/css/settings.scss index ea962ae73f..0ed3a2df3a 100644 --- a/client/webserver/site/src/css/settings.scss +++ b/client/webserver/site/src/css/settings.scss @@ -49,3 +49,8 @@ div.settings-exchange { word-break: break-all; user-select: all; } + +.dex-settings-icon { + font-size: 15; + margin-left: 0.5rem; +} diff --git a/client/webserver/site/src/font/icomoon.svg b/client/webserver/site/src/font/icomoon.svg index 2f280e1ee9..3c666bae38 100644 --- a/client/webserver/site/src/font/icomoon.svg +++ b/client/webserver/site/src/font/icomoon.svg @@ -25,8 +25,9 @@ - + + diff --git a/client/webserver/site/src/font/icomoon.ttf b/client/webserver/site/src/font/icomoon.ttf index 6f261545d1..0e34ef1c53 100644 Binary files a/client/webserver/site/src/font/icomoon.ttf and b/client/webserver/site/src/font/icomoon.ttf differ diff --git a/client/webserver/site/src/font/icomoon.woff b/client/webserver/site/src/font/icomoon.woff index 5e44f169e9..ef8a926024 100644 Binary files a/client/webserver/site/src/font/icomoon.woff and b/client/webserver/site/src/font/icomoon.woff differ diff --git a/client/webserver/site/src/html/dexsettings.tmpl b/client/webserver/site/src/html/dexsettings.tmpl new file mode 100644 index 0000000000..7b10ec37d6 --- /dev/null +++ b/client/webserver/site/src/html/dexsettings.tmpl @@ -0,0 +1,41 @@ +{{define "dexsettings"}} +{{template "top" .}} +{{$passwordIsCached := .UserInfo.PasswordIsCached}} +
+ +
{{.Exchange.Host}}
+
+ + +

+
+
+
+
+ +
+
+ +
+
+ + + [[[successful_cert_update]]] +
+
+ +
+ {{- /* DISABLE ACCOUNT */ -}} +
+ {{template "disableAccountForm"}} +
+ + {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} +
+ {{template "authorizeAccountExportForm"}} +
+
+ +
+{{template "bottom"}} +{{end}} diff --git a/client/webserver/site/src/html/settings.tmpl b/client/webserver/site/src/html/settings.tmpl index 83cbf8ce36..b8fdcfa2e5 100644 --- a/client/webserver/site/src/html/settings.tmpl +++ b/client/webserver/site/src/html/settings.tmpl @@ -2,9 +2,7 @@ {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}}
-
-
@@ -20,26 +18,13 @@
+
[[[registered dexes]]]
{{range $host, $xc := .UserInfo.Exchanges}} -
- - [[[DEX Address]]]: {{$host}} - -
- [[[Account ID]]]: - {{if eq (len $xc.AcctID) 0}} - <login to show> - {{else}} - {{$xc.AcctID}} -
- {{end}}
- - -
+ {{end}}

- +

[[[simultaneous_servers_msg]]]

@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} -
- {{template "authorizeAccountExportForm"}} -
- {{- /* AUTHORIZE IMPORT ACCOUNT */ -}}
{{template "authorizeAccountImportForm" .}} @@ -89,11 +69,6 @@ {{template "newWalletForm" }}
- {{- /* DISABLE ACCOUNT */ -}} -
- {{template "disableAccountForm"}} -
- {{- /* CHANGE APP PASSWORD */ -}}
{{template "changeAppPWForm"}} diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index debc640c41..cc1ec7e413 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -7,6 +7,7 @@ import SettingsPage from './settings' import MarketsPage from './markets' import OrdersPage from './orders' import OrderPage from './order' +import DexSettingsPage from './dexsettings' import { RateEncodingFactor, StatusExecuted, hasLiveMatches } from './orderutil' import { getJSON, postJSON } from './http' import * as ntfn from './notifications' @@ -67,7 +68,8 @@ const constructors: Record = { wallets: WalletsPage, settings: SettingsPage, orders: OrdersPage, - order: OrderPage + order: OrderPage, + dexsettings: DexSettingsPage } // unathedPages are pages that don't require authorization to load. @@ -578,7 +580,7 @@ export default class Application { case 'conn': { const n = note as ConnEventNote const xc = this.user.exchanges[n.host] - if (xc) xc.connected = n.connected + if (xc) xc.connectionStatus = n.connectionStatus break } case 'spots': { @@ -730,6 +732,7 @@ export default class Application { */ haveAssetOrders (assetID: number): boolean { for (const xc of Object.values(this.user.exchanges)) { + if (!xc.markets) continue for (const market of Object.values(xc.markets)) { if (!market.orders) continue for (const ord of market.orders) { diff --git a/client/webserver/site/src/js/dexsettings.ts b/client/webserver/site/src/js/dexsettings.ts new file mode 100644 index 0000000000..2ad146dd40 --- /dev/null +++ b/client/webserver/site/src/js/dexsettings.ts @@ -0,0 +1,194 @@ +import Doc from './doc' +import BasePage from './basepage' +import State from './state' +import { postJSON } from './http' +import * as forms from './forms' + +import { + app, + PageElement, + ConnectionStatus +} from './registry' + +const animationLength = 300 + +export default class DexSettingsPage extends BasePage { + body: HTMLElement + forms: PageElement[] + currentForm: PageElement + page: Record + host: string + keyup: (e: KeyboardEvent) => void + + constructor (body: HTMLElement) { + super() + this.body = body + this.host = body.dataset.host ? body.dataset.host : '' + const page = this.page = Doc.idDescendants(body) + this.forms = Doc.applySelector(page.forms, ':scope > form') + + Doc.bind(page.exportDexBtn, 'click', () => this.prepareAccountExport(page.authorizeAccountExportForm)) + Doc.bind(page.disableAcctBtn, 'click', () => this.prepareAccountDisable(page.disableAccountForm)) + Doc.bind(page.updateCertBtn, 'click', () => page.certFileInput.click()) + Doc.bind(page.certFileInput, 'change', () => this.onCertFileChange()) + + forms.bind(page.authorizeAccountExportForm, page.authorizeExportAccountConfirm, () => this.exportAccount()) + forms.bind(page.disableAccountForm, page.disableAccountConfirm, () => this.disableAccount()) + + const closePopups = () => { + Doc.hide(page.forms) + page.exportSeedPW.value = '' + page.seedDiv.textContent = '' + } + + Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => { + if (!Doc.mouseInElement(e, this.currentForm)) { closePopups() } + }) + + this.keyup = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closePopups() + } + } + Doc.bind(document, 'keyup', this.keyup) + + page.forms.querySelectorAll('.form-closer').forEach(el => { + Doc.bind(el, 'click', () => { closePopups() }) + }) + + this.notifiers = { + conn: () => { this.setConnectionStatus() } + } + + this.setConnectionStatus() + } + + /* showForm shows a modal form with a little animation. */ + async showForm (form: HTMLElement) { + const page = this.page + this.currentForm = form + this.forms.forEach(form => Doc.hide(form)) + form.style.right = '10000px' + Doc.show(page.forms, form) + const shift = (page.forms.offsetWidth + form.offsetWidth) / 2 + await Doc.animate(animationLength, progress => { + form.style.right = `${(1 - progress) * shift}px` + }, 'easeOutHard') + form.style.right = '0' + } + + // exportAccount exports and downloads the account info. + async exportAccount () { + const page = this.page + const pw = page.exportAccountAppPass.value + const host = page.exportAccountHost.textContent + page.exportAccountAppPass.value = '' + const req = { + pw, + host + } + const loaded = app().loading(this.body) + const res = await postJSON('/api/exportaccount', req) + loaded() + if (!app().checkResponse(res)) { + page.exportAccountErr.textContent = res.msg + Doc.show(page.exportAccountErr) + return + } + const accountForExport = JSON.parse(JSON.stringify(res.account)) + const a = document.createElement('a') + a.setAttribute('download', 'dcrAccount-' + host + '.json') + a.setAttribute('href', 'data:text/json,' + JSON.stringify(accountForExport, null, 2)) + a.click() + Doc.hide(page.forms) + } + + // disableAccount disables the account associated with the provided host. + async disableAccount () { + const page = this.page + const pw = page.disableAccountAppPW.value + const host = page.disableAccountHost.textContent + page.disableAccountAppPW.value = '' + const req = { + pw, + host + } + const loaded = app().loading(this.body) + const res = await postJSON('/api/disableaccount', req) + loaded() + if (!app().checkResponse(res, true)) { + page.disableAccountErr.textContent = res.msg + Doc.show(page.disableAccountErr) + return + } + Doc.hide(page.forms) + window.location.assign('/settings') + } + + async prepareAccountExport (authorizeAccountExportForm: HTMLElement) { + const page = this.page + page.exportAccountHost.textContent = this.host + page.exportAccountErr.textContent = '' + if (State.passwordIsCached()) { + this.exportAccount() + } else { + this.showForm(authorizeAccountExportForm) + } + } + + async prepareAccountDisable (disableAccountForm: HTMLElement) { + const page = this.page + page.disableAccountHost.textContent = this.host + page.disableAccountErr.textContent = '' + this.showForm(disableAccountForm) + } + + async onCertFileChange () { + const page = this.page + Doc.hide(page.errMsg) + const files = page.certFileInput.files + let cert + if (files && files.length) cert = await files[0].text() + if (!cert) return + const req = { host: this.host, cert: cert } + const loaded = app().loading(this.body) + const res = await postJSON('/api/updatecert', req) + loaded() + if (!app().checkResponse(res, true)) { + page.errMsg.textContent = res.msg + Doc.show(page.errMsg) + } else { + Doc.show(page.updateCertMsg) + setTimeout(() => { Doc.hide(page.updateCertMsg) }, 5000) + } + } + + setConnectionStatus () { + const page = this.page + const exchange = app().user.exchanges[this.host] + const displayIcons = (connected: boolean) => { + if (connected) { + Doc.hide(page.disconnectedIcon) + Doc.show(page.connectedIcon) + } else { + Doc.show(page.disconnectedIcon) + Doc.hide(page.connectedIcon) + } + } + if (exchange) { + switch (exchange.connectionStatus) { + case ConnectionStatus.Connected: + displayIcons(true) + page.connectionStatus.textContent = 'Connected' + break + case ConnectionStatus.Disconnected: + displayIcons(false) + page.connectionStatus.textContent = 'Disconnected' + break + case ConnectionStatus.InvalidCert: + displayIcons(false) + page.connectionStatus.textContent = 'Disconnected - Invalid Certificate' + } + } + } +} diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 3140395a59..b9ae184676 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -48,7 +48,8 @@ import { RemainderUpdate, ConnEventNote, Spot, - OrderOption + OrderOption, + ConnectionStatus } from './registry' const bind = Doc.bind @@ -245,6 +246,7 @@ export default class MarketsPage extends BasePage { // Prepare the list of markets. this.marketList = new MarketList(page.marketList) for (const xc of this.marketList.xcSections) { + if (!xc.marketRows) continue for (const row of xc.marketRows) { bind(row.node, 'click', () => { this.setMarket(xc.host, row.mkt.baseid, row.mkt.quoteid) @@ -620,7 +622,7 @@ export default class MarketsPage extends BasePage { // If dex is not connected to server, is not possible to know fee // registration status. - if (!dex.connected) return + if (dex.connectionStatus !== ConnectionStatus.Connected) return this.updateRegistrationStatusView() @@ -671,7 +673,7 @@ export default class MarketsPage extends BasePage { // If we have not yet connected, there is no dex.assets or any other // exchange data, so just put up a message and wait for the connection to be // established, at which time handleConnNote will refresh and reload. - if (!dex.connected) { + if (dex.connectionStatus !== ConnectionStatus.Connected) { // TODO: Figure out why this was like this. // this.market = { dex: dex } @@ -1737,7 +1739,7 @@ export default class MarketsPage extends BasePage { } setBalanceVisibility () { - if (this.market.dex.connected) { + if (this.market.dex.connectionStatus === ConnectionStatus.Connected) { Doc.show(this.page.balanceTable) } else { Doc.hide(this.page.balanceTable) @@ -1749,7 +1751,7 @@ export default class MarketsPage extends BasePage { this.setBalanceVisibility() // if connection to dex server fails, it is not possible to retrieve // markets. - if (!this.market.dex.connected) return + if (this.market.dex.connectionStatus !== ConnectionStatus.Connected) return this.balanceWgt.updateAsset(note.assetID) // If there's a balance update, refresh the max order section. const mkt = this.market @@ -2070,7 +2072,7 @@ export default class MarketsPage extends BasePage { */ async handleConnNote (note: ConnEventNote) { this.marketList.setConnectionStatus(note) - if (note.connected) { + if (note.connectionStatus === ConnectionStatus.Connected) { // Having been disconnected from a DEX server, anything may have changed, // or this may be the first opportunity to get the server's config, so // fetch it all before reloading the markets page. @@ -2259,7 +2261,7 @@ class MarketList { setConnectionStatus (note: ConnEventNote) { const xcSection = this.xcSection(note.host) if (!xcSection) return console.error(`setConnectionStatus: no exchange section for ${note.host}`) - xcSection.setConnected(note.connected) + xcSection.setConnected(note.connectionStatus === ConnectionStatus.Connected) } /* @@ -2290,7 +2292,7 @@ class ExchangeSection { tmpl.header.textContent = dex.host this.disconnectedIco = tmpl.disconnected - if (dex.connected) Doc.hide(tmpl.disconnected) + if (dex.connectionStatus === ConnectionStatus.Connected) Doc.hide(tmpl.disconnected) tmpl.mkts.removeChild(tmpl.mktrow) // If disconnected is not possible to get the markets from the server. @@ -2311,6 +2313,7 @@ class ExchangeSection { * symbol first, quote symbol second. */ sortedMarkets () { + if (!this.marketRows) return [] return [...this.marketRows].sort((a, b) => a.name < b.name ? -1 : 1) } diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index c8092fffec..f7dcf89b24 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -8,12 +8,18 @@ declare global { } } +export enum ConnectionStatus { + Disconnected = 0, + Connected = 1, + InvalidCert = 2, +} + export interface Exchange { host: string acctID: string markets: Record assets: Record - connected: boolean + connectionStatus: ConnectionStatus feeAsset: FeeAsset // DEPRECATED. DCR. regFees: Record pendingFee: PendingFeeState | null @@ -276,7 +282,7 @@ export interface MatchNote extends CoreNote { export interface ConnEventNote extends CoreNote { host: string - connected: boolean + connectionStatus: ConnectionStatus } export interface OrderNote extends CoreNote { diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 205101357e..211150eb83 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -111,21 +111,6 @@ export default class SettingsPage extends BasePage { this.animateRegAsset(page.dexAddrForm) }) - forms.bind(page.authorizeAccountExportForm, page.authorizeExportAccountConfirm, () => this.exportAccount()) - forms.bind(page.disableAccountForm, page.disableAccountConfirm, () => this.disableAccount()) - - const exchangesDiv = page.exchanges - if (typeof app().user.exchanges !== 'undefined') { - for (const host of Object.keys(app().user.exchanges)) { - // Bind export account button click event. - const exportAccountButton = Doc.tmplElement(exchangesDiv, `exportAccount-${host}`) - Doc.bind(exportAccountButton, 'click', () => this.prepareAccountExport(host, page.authorizeAccountExportForm)) - // Bind disable account button click event. - const disableAccountButton = Doc.tmplElement(exchangesDiv, `disableAccount-${host}`) - Doc.bind(disableAccountButton, 'click', () => this.prepareAccountDisable(host, page.disableAccountForm)) - } - } - Doc.bind(page.importAccount, 'click', () => this.prepareAccountImport(page.authorizeAccountImportForm)) forms.bind(page.authorizeAccountImportForm, page.authorizeImportAccountConfirm, () => this.importAccount()) @@ -201,73 +186,6 @@ export default class SettingsPage extends BasePage { await forms.slideSwap(page.newWalletForm, page.walletWait) } - async prepareAccountExport (host: string, authorizeAccountExportForm: HTMLElement) { - const page = this.page - page.exportAccountHost.textContent = host - page.exportAccountErr.textContent = '' - if (State.passwordIsCached()) { - this.exportAccount() - } else { - this.showForm(authorizeAccountExportForm) - } - } - - async prepareAccountDisable (host: string, disableAccountForm: HTMLElement) { - const page = this.page - page.disableAccountHost.textContent = host - page.disableAccountErr.textContent = '' - this.showForm(disableAccountForm) - } - - // exportAccount exports and downloads the account info. - async exportAccount () { - const page = this.page - const pw = page.exportAccountAppPass.value - const host = page.exportAccountHost.textContent - page.exportAccountAppPass.value = '' - const req = { - pw, - host - } - const loaded = app().loading(this.body) - const res = await postJSON('/api/exportaccount', req) - loaded() - if (!app().checkResponse(res)) { - page.exportAccountErr.textContent = res.msg - Doc.show(page.exportAccountErr) - return - } - const accountForExport = JSON.parse(JSON.stringify(res.account)) - const a = document.createElement('a') - a.setAttribute('download', 'dcrAccount-' + host + '.json') - a.setAttribute('href', 'data:text/json,' + JSON.stringify(accountForExport, null, 2)) - a.click() - Doc.hide(page.forms) - } - - // disableAccount disables the account associated with the provided host. - async disableAccount () { - const page = this.page - const pw = page.disableAccountAppPW.value - const host = page.disableAccountHost.textContent - page.disableAccountAppPW.value = '' - const req = { - pw, - host - } - const loaded = app().loading(this.body) - const res = await postJSON('/api/disableaccount', req) - loaded() - if (!app().checkResponse(res, true)) { - page.disableAccountErr.textContent = res.msg - Doc.show(page.disableAccountErr) - return - } - Doc.hide(page.forms) - // Initial method of removing disabled account. - window.location.reload() - } - async onAccountFileChange () { const page = this.page const files = page.accountFile.files diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index 4cbd707cde..0cb83c2a6a 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -300,6 +300,7 @@ export default class WalletsPage extends BasePage { page.marketsForLogo.src = Doc.logoPath(app().assets[assetID].symbol) for (const [host, xc] of Object.entries(app().user.exchanges)) { let count = 0 + if (!xc.markets) continue for (const market of Object.values(xc.markets)) { if (market.baseid === assetID || market.quoteid === assetID) count++ } @@ -308,6 +309,7 @@ export default class WalletsPage extends BasePage { const tmpl = Doc.parseTemplate(marketBox) tmpl.dexTitle.textContent = host card.appendChild(marketBox) + if (!xc.markets) continue for (const market of Object.values(xc.markets)) { // Only show markets where this is the base or quote asset. if (market.baseid !== assetID && market.quoteid !== assetID) continue diff --git a/client/webserver/site/src/localized_html/en-US/dexsettings.tmpl b/client/webserver/site/src/localized_html/en-US/dexsettings.tmpl new file mode 100644 index 0000000000..79cec8210a --- /dev/null +++ b/client/webserver/site/src/localized_html/en-US/dexsettings.tmpl @@ -0,0 +1,41 @@ +{{define "dexsettings"}} +{{template "top" .}} +{{$passwordIsCached := .UserInfo.PasswordIsCached}} +
+ +
{{.Exchange.Host}}
+
+ + +

+
+
+
+
+ +
+
+ +
+
+ + + Successfully updated certificate! +
+
+ +
+ {{- /* DISABLE ACCOUNT */ -}} + + {{template "disableAccountForm"}} + + + {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} +
+ {{template "authorizeAccountExportForm"}} +
+
+ +
+{{template "bottom"}} +{{end}} diff --git a/client/webserver/site/src/localized_html/en-US/settings.tmpl b/client/webserver/site/src/localized_html/en-US/settings.tmpl index eb6ce30505..9e9907c588 100644 --- a/client/webserver/site/src/localized_html/en-US/settings.tmpl +++ b/client/webserver/site/src/localized_html/en-US/settings.tmpl @@ -2,9 +2,7 @@ {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}}
-
-
@@ -20,26 +18,13 @@
+
Registered Dexes:
{{range $host, $xc := .UserInfo.Exchanges}} -
- - DEX Address: {{$host}} - -
- Account ID: - {{if eq (len $xc.AcctID) 0}} - <login to show> - {{else}} - {{$xc.AcctID}} -
- {{end}}
- - -
+ {{end}}

- +

The Decred DEX Client supports simultaneous use of any number of DEX servers.

@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} -
- {{template "authorizeAccountExportForm"}} -
- {{- /* AUTHORIZE IMPORT ACCOUNT */ -}}
{{template "authorizeAccountImportForm" .}} @@ -89,11 +69,6 @@ {{template "newWalletForm" }}
- {{- /* DISABLE ACCOUNT */ -}} -
- {{template "disableAccountForm"}} -
- {{- /* CHANGE APP PASSWORD */ -}}
{{template "changeAppPWForm"}} diff --git a/client/webserver/site/src/localized_html/pl-PL/dexsettings.tmpl b/client/webserver/site/src/localized_html/pl-PL/dexsettings.tmpl new file mode 100644 index 0000000000..b4df3218d8 --- /dev/null +++ b/client/webserver/site/src/localized_html/pl-PL/dexsettings.tmpl @@ -0,0 +1,41 @@ +{{define "dexsettings"}} +{{template "top" .}} +{{$passwordIsCached := .UserInfo.PasswordIsCached}} +
+ +
{{.Exchange.Host}}
+
+ + +

+
+
+
+
+ +
+
+ +
+
+ + + Successfully updated certificate! +
+
+ +
+ {{- /* DISABLE ACCOUNT */ -}} + + {{template "disableAccountForm"}} + + + {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} +
+ {{template "authorizeAccountExportForm"}} +
+
+ +
+{{template "bottom"}} +{{end}} diff --git a/client/webserver/site/src/localized_html/pl-PL/settings.tmpl b/client/webserver/site/src/localized_html/pl-PL/settings.tmpl index b314280849..734b5984ec 100644 --- a/client/webserver/site/src/localized_html/pl-PL/settings.tmpl +++ b/client/webserver/site/src/localized_html/pl-PL/settings.tmpl @@ -2,9 +2,7 @@ {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}}
-
-
@@ -20,26 +18,13 @@
+
Registered Dexes:
{{range $host, $xc := .UserInfo.Exchanges}} -
- - Adres DEX: {{$host}} - -
- ID konta: - {{if eq (len $xc.AcctID) 0}} - <login to show> - {{else}} - {{$xc.AcctID}} -
- {{end}}
- - -
+ {{end}}

- +

Klient Decred DEX wspiera jednoczesne korzystanie z wielu serwerów DEX.

@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} -
- {{template "authorizeAccountExportForm"}} -
- {{- /* AUTHORIZE IMPORT ACCOUNT */ -}}
{{template "authorizeAccountImportForm" .}} @@ -89,11 +69,6 @@ {{template "newWalletForm" }}
- {{- /* DISABLE ACCOUNT */ -}} -
- {{template "disableAccountForm"}} -
- {{- /* CHANGE APP PASSWORD */ -}}
{{template "changeAppPWForm"}} diff --git a/client/webserver/site/src/localized_html/pt-BR/dexsettings.tmpl b/client/webserver/site/src/localized_html/pt-BR/dexsettings.tmpl new file mode 100644 index 0000000000..3fb5183786 --- /dev/null +++ b/client/webserver/site/src/localized_html/pt-BR/dexsettings.tmpl @@ -0,0 +1,41 @@ +{{define "dexsettings"}} +{{template "top" .}} +{{$passwordIsCached := .UserInfo.PasswordIsCached}} +
+ +
{{.Exchange.Host}}
+
+ + +

+
+
+
+
+ +
+
+ +
+
+ + + Successfully updated certificate! +
+
+ +
+ {{- /* DISABLE ACCOUNT */ -}} + + {{template "disableAccountForm"}} + + + {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} +
+ {{template "authorizeAccountExportForm"}} +
+
+ +
+{{template "bottom"}} +{{end}} diff --git a/client/webserver/site/src/localized_html/pt-BR/settings.tmpl b/client/webserver/site/src/localized_html/pt-BR/settings.tmpl index 5d5c2be02a..d6ef83ffb7 100644 --- a/client/webserver/site/src/localized_html/pt-BR/settings.tmpl +++ b/client/webserver/site/src/localized_html/pt-BR/settings.tmpl @@ -2,9 +2,7 @@ {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}}
-
-
@@ -20,26 +18,13 @@
+
Registered Dexes:
{{range $host, $xc := .UserInfo.Exchanges}} -
- - Endereço DEX: {{$host}} - -
- ID da Conta: - {{if eq (len $xc.AcctID) 0}} - <login to show> - {{else}} - {{$xc.AcctID}} -
- {{end}}
- - -
+ {{end}}

- +

O cliente da DEX suporta simultâneos números de servidores DEX.

@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} -
- {{template "authorizeAccountExportForm"}} -
- {{- /* AUTHORIZE IMPORT ACCOUNT */ -}}
{{template "authorizeAccountImportForm" .}} @@ -89,11 +69,6 @@ {{template "newWalletForm" }}
- {{- /* DISABLE ACCOUNT */ -}} -
- {{template "disableAccountForm"}} -
- {{- /* CHANGE APP PASSWORD */ -}}
{{template "changeAppPWForm"}} diff --git a/client/webserver/site/src/localized_html/zh-CN/dexsettings.tmpl b/client/webserver/site/src/localized_html/zh-CN/dexsettings.tmpl new file mode 100644 index 0000000000..3be195e06f --- /dev/null +++ b/client/webserver/site/src/localized_html/zh-CN/dexsettings.tmpl @@ -0,0 +1,41 @@ +{{define "dexsettings"}} +{{template "top" .}} +{{$passwordIsCached := .UserInfo.PasswordIsCached}} +
+ +
{{.Exchange.Host}}
+
+ + +

+
+
+
+
+ +
+
+ +
+
+ + + Successfully updated certificate! +
+
+ +
+ {{- /* DISABLE ACCOUNT */ -}} + + {{template "disableAccountForm"}} + + + {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} +
+ {{template "authorizeAccountExportForm"}} +
+
+ +
+{{template "bottom"}} +{{end}} diff --git a/client/webserver/site/src/localized_html/zh-CN/settings.tmpl b/client/webserver/site/src/localized_html/zh-CN/settings.tmpl index c4ab3731e4..ffcfe7134d 100644 --- a/client/webserver/site/src/localized_html/zh-CN/settings.tmpl +++ b/client/webserver/site/src/localized_html/zh-CN/settings.tmpl @@ -2,9 +2,7 @@ {{template "top" .}} {{$passwordIsCached := .UserInfo.PasswordIsCached}}
-
-
@@ -20,26 +18,13 @@
+
Registered Dexes:
{{range $host, $xc := .UserInfo.Exchanges}} -
- - DEX 地址: {{$host}} - -
- 帐户 ID: - {{if eq (len $xc.AcctID) 0}} - <login to show> - {{else}} - {{$xc.AcctID}} -
- {{end}}
- - -
+ {{end}}

- +

DEX 客户端支持同时使用多个 DEX 服务器。

@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} -
- {{template "authorizeAccountExportForm"}} -
- {{- /* AUTHORIZE IMPORT ACCOUNT */ -}}
{{template "authorizeAccountImportForm" .}} @@ -89,11 +69,6 @@ {{template "newWalletForm" }}
- {{- /* DISABLE ACCOUNT */ -}} -
- {{template "disableAccountForm"}} -
- {{- /* CHANGE APP PASSWORD */ -}}
{{template "changeAppPWForm"}} diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 737e41af43..3ad47cadd7 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -75,6 +75,7 @@ type clientCore interface { websocket.Core Network() dex.Network Exchanges() map[string]*core.Exchange + Exchange(host string) (*core.Exchange, error) Register(*core.RegisterForm) (*core.RegisterResult, error) Login(pw []byte) (*core.LoginResult, error) InitializeClient(pw, seed []byte) error @@ -116,6 +117,7 @@ type clientCore interface { PreAccelerateOrder(oidB dex.Bytes) (*core.PreAccelerate, error) AccelerateOrder(pw []byte, oidB dex.Bytes, newFeeRate uint64) (string, error) AccelerationEstimate(oidB dex.Bytes, newFeeRate uint64) (uint64, error) + UpdateCert(host string, cert []byte) error } var _ clientCore = (*core.Core)(nil) @@ -268,6 +270,7 @@ func New(cfg *Config) (*WebServer, error) { // after initial setup. web.Get(registerRoute, s.handleRegister) web.Get(settingsRoute, s.handleSettings) + web.With(dexHostCtx).Get("/dexsettings/{host}", s.handleDexSettings) web.Get("/generateqrcode", s.handleGenerateQRCode) @@ -350,6 +353,7 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/accelerateorder", s.apiAccelerateOrder) apiAuth.Post("/preaccelerate", s.apiPreAccelerate) apiAuth.Post("/accelerationestimate", s.apiAccelerationEstimate) + apiAuth.Post("/updatecert", s.apiUpdateCert) }) }) @@ -424,7 +428,8 @@ func (s *WebServer) buildTemplates(lang string) error { addTemplate("wallets", bb, "forms"). addTemplate("settings", bb, "forms"). addTemplate("orders", bb). - addTemplate("order", bb, "forms") + addTemplate("order", bb, "forms"). + addTemplate("dexsettings", bb, "forms") return s.html.buildErr() } diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index d77f949c29..b791ea6bc7 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -71,8 +71,9 @@ type TCore struct { notOpen bool } -func (c *TCore) Network() dex.Network { return dex.Mainnet } -func (c *TCore) Exchanges() map[string]*core.Exchange { return nil } +func (c *TCore) Network() dex.Network { return dex.Mainnet } +func (c *TCore) Exchanges() map[string]*core.Exchange { return nil } +func (c *TCore) Exchange(host string) (*core.Exchange, error) { return nil, nil } func (c *TCore) GetDEXConfig(dexAddr string, certI interface{}) (*core.Exchange, error) { return nil, c.getDEXConfigErr // TODO along with test for apiUser / Exchanges() / User() } @@ -188,6 +189,9 @@ func (c *TCore) PreAccelerateOrder(oidB dex.Bytes) (*core.PreAccelerate, error) func (c *TCore) RecoverWallet(uint32, []byte, bool) error { return nil } +func (c *TCore) UpdateCert(string, []byte) error { + return nil +} type TWriter struct { b []byte diff --git a/dex/runner.go b/dex/runner.go index e6ae29e7b2..fcf6d65418 100644 --- a/dex/runner.go +++ b/dex/runner.go @@ -110,6 +110,10 @@ func (c *ConnectionMaster) Connect(ctx context.Context) (err error) { c.init(ctx) c.mtx.Lock() c.wg, err = c.connector.Connect(c.ctx) + if c.wg == nil { // don't expect a waitgroup if Connect errored + c.wg = new(sync.WaitGroup) // don't let Disconnect or Wait panic + c.cancel() // we're not "On" + } c.mtx.Unlock() // NOTE: Even if err is non-nil, we can't cancel the internal context // because the connector may be attempting to reconnect. The caller should