decred.org/dcrdex@v1.0.5/client/webserver/webserver.go (about) 1 // This code is available on the terms of the project LICENSE.md file, 2 // also available online at https://blueoakcouncil.org/license/1.0.0. 3 4 package webserver 5 6 import ( 7 "context" 8 "crypto/elliptic" 9 crand "crypto/rand" 10 "crypto/tls" 11 "embed" 12 "encoding/hex" 13 "encoding/json" 14 "errors" 15 "fmt" 16 "io" 17 "io/fs" 18 "mime" 19 "net" 20 "net/http" 21 "os" 22 "path" 23 "path/filepath" 24 "regexp" 25 "runtime" 26 "strconv" 27 "strings" 28 "sync" 29 "sync/atomic" 30 "time" 31 32 "decred.org/dcrdex/client/asset" 33 "decred.org/dcrdex/client/core" 34 "decred.org/dcrdex/client/db" 35 "decred.org/dcrdex/client/mm" 36 "decred.org/dcrdex/client/mm/libxc" 37 "decred.org/dcrdex/client/webserver/locales" 38 "decred.org/dcrdex/client/websocket" 39 "decred.org/dcrdex/dex" 40 "decred.org/dcrdex/dex/dexnet" 41 "decred.org/dcrdex/dex/encode" 42 "decred.org/dcrdex/dex/encrypt" 43 "decred.org/dcrdex/dex/version" 44 "github.com/decred/dcrd/certgen" 45 "github.com/go-chi/chi/v5" 46 "github.com/go-chi/chi/v5/middleware" 47 "golang.org/x/text/language" 48 ) 49 50 // contextKey is the key param type used when saving values to a context using 51 // context.WithValue. A custom type is defined because built-in types are 52 // discouraged. 53 type contextKey string 54 55 const ( 56 // httpConnTimeoutSeconds is the maximum number of seconds allowed for 57 // reading an http request or writing the response, beyond which the http 58 // connection is terminated. 59 httpConnTimeoutSeconds = 10 60 // authCK is the authorization token cookie key. 61 authCK = "dexauth" 62 // pwKeyCK is the cookie used to unencrypt the user's password. 63 pwKeyCK = "sessionkey" 64 // ctxKeyUserInfo is used in the authorization middleware for saving user 65 // info in http request contexts. 66 ctxKeyUserInfo = contextKey("userinfo") 67 // notifyRoute is a route used for general notifications. 68 notifyRoute = "notify" 69 // The basis for content-security-policy. connect-src must be the final 70 // directive so that it can be reliably supplemented on startup. 71 baseCSP = "default-src 'none'; script-src 'self'; img-src 'self' data:; style-src 'self'; font-src 'self'; connect-src 'self'" 72 // site is the common prefix for the site resources with respect to this 73 // webserver package. 74 site = "site" 75 ) 76 77 var ( 78 // errNoCachedPW is returned when attempting to retrieve a cached password, but the 79 // cookie that should contain the cached password is not populated. 80 errNoCachedPW = errors.New("no cached password") 81 ) 82 83 var ( 84 log dex.Logger 85 unbip = dex.BipIDSymbol 86 87 //go:embed site/src/html/*.tmpl 88 htmlTmplRes embed.FS 89 htmlTmplSub, _ = fs.Sub(htmlTmplRes, "site/src/html") // unrooted slash separated path as per io/fs.ValidPath 90 91 //go:embed site/dist site/src/img site/src/font 92 staticSiteRes embed.FS 93 94 latestVersionRegex = regexp.MustCompile(`\d+(\.\d+)+`) 95 ) 96 97 // clientCore is satisfied by core.Core. 98 type clientCore interface { 99 websocket.Core 100 Network() dex.Network 101 Exchanges() map[string]*core.Exchange 102 Exchange(host string) (*core.Exchange, error) 103 PostBond(form *core.PostBondForm) (*core.PostBondResult, error) 104 RedeemPrepaidBond(appPW []byte, code []byte, host string, certI any) (tier uint64, err error) 105 UpdateBondOptions(form *core.BondOptionsForm) error 106 Login(pw []byte) error 107 InitializeClient(pw []byte, seed *string) (string, error) 108 AssetBalance(assetID uint32) (*core.WalletBalance, error) 109 CreateWallet(appPW, walletPW []byte, form *core.WalletForm) error 110 OpenWallet(assetID uint32, pw []byte) error 111 RescanWallet(assetID uint32, force bool) error 112 RecoverWallet(assetID uint32, appPW []byte, force bool) error 113 CloseWallet(assetID uint32) error 114 ConnectWallet(assetID uint32) error 115 Wallets() []*core.WalletState 116 WalletState(assetID uint32) *core.WalletState 117 WalletSettings(uint32) (map[string]string, error) 118 ReconfigureWallet([]byte, []byte, *core.WalletForm) error 119 ToggleWalletStatus(assetID uint32, disable bool) error 120 ChangeAppPass([]byte, []byte) error 121 ResetAppPass(newPass []byte, seed string) error 122 NewDepositAddress(assetID uint32) (string, error) 123 AddressUsed(assetID uint32, addr string) (bool, error) 124 AutoWalletConfig(assetID uint32, walletType string) (map[string]string, error) 125 User() *core.User 126 GetDEXConfig(dexAddr string, certI any) (*core.Exchange, error) 127 AddDEX(appPW []byte, dexAddr string, certI any) error 128 DiscoverAccount(dexAddr string, pass []byte, certI any) (*core.Exchange, bool, error) 129 SupportedAssets() map[uint32]*core.SupportedAsset 130 Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) 131 Trade(pw []byte, form *core.TradeForm) (*core.Order, error) 132 TradeAsync(pw []byte, form *core.TradeForm) (*core.InFlightOrder, error) 133 Cancel(oid dex.Bytes) error 134 NotificationFeed() *core.NoteFeed 135 Logout() error 136 Orders(*core.OrderFilter) ([]*core.Order, error) 137 Order(oid dex.Bytes) (*core.Order, error) 138 MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) 139 MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) 140 AccountExport(pw []byte, host string) (*core.Account, []*db.Bond, error) 141 AccountImport(pw []byte, account *core.Account, bonds []*db.Bond) error 142 ToggleAccountStatus(pw []byte, host string, disable bool) error 143 IsInitialized() bool 144 ExportSeed(pw []byte) (string, error) 145 PreOrder(*core.TradeForm) (*core.OrderEstimate, error) 146 WalletLogFilePath(assetID uint32) (string, error) 147 BondsFeeBuffer(assetID uint32) (uint64, error) 148 PreAccelerateOrder(oidB dex.Bytes) (*core.PreAccelerate, error) 149 AccelerateOrder(pw []byte, oidB dex.Bytes, newFeeRate uint64) (string, error) 150 AccelerationEstimate(oidB dex.Bytes, newFeeRate uint64) (uint64, error) 151 UpdateCert(host string, cert []byte) error 152 UpdateDEXHost(oldHost, newHost string, appPW []byte, certI any) (*core.Exchange, error) 153 WalletRestorationInfo(pw []byte, assetID uint32) ([]*asset.WalletRestoration, error) 154 ToggleRateSourceStatus(src string, disable bool) error 155 FiatRateSources() map[string]bool 156 EstimateSendTxFee(address string, assetID uint32, value uint64, subtract, maxWithdraw bool) (fee uint64, isValidAddress bool, err error) 157 ValidateAddress(address string, assetID uint32) (bool, error) 158 DeleteArchivedRecordsWithBackup(olderThan *time.Time, saveMatchesToFile, saveOrdersToFile bool) (string, int, error) 159 WalletPeers(assetID uint32) ([]*asset.WalletPeer, error) 160 AddWalletPeer(assetID uint32, addr string) error 161 RemoveWalletPeer(assetID uint32, addr string) error 162 Notifications(n int) (notes, pokes []*db.Notification, _ error) 163 ApproveToken(appPW []byte, assetID uint32, dexAddr string, onConrim func()) (string, error) 164 UnapproveToken(appPW []byte, assetID uint32, version uint32) (string, error) 165 ApproveTokenFee(assetID uint32, version uint32, approval bool) (uint64, error) 166 StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) 167 SetVSP(assetID uint32, addr string) error 168 PurchaseTickets(assetID uint32, pw []byte, n int) error 169 SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error 170 ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error) 171 TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error) 172 TxHistory(assetID uint32, n int, refID *string, past bool) ([]*asset.WalletTransaction, error) 173 FundsMixingStats(assetID uint32) (*asset.FundsMixingStats, error) 174 ConfigureFundsMixer(appPW []byte, assetID uint32, enabled bool) error 175 SetLanguage(string) error 176 Language() string 177 TakeAction(assetID uint32, actionID string, actionB json.RawMessage) error 178 RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64, error) 179 ExtensionModeConfig() *core.ExtensionModeConfig 180 } 181 182 type MMCore interface { 183 MarketReport(host string, base, quote uint32) (*mm.MarketReport, error) 184 StartBot(mkt *mm.StartConfig, alternateConfigPath *string, pw []byte, overrideLotSizeChange bool) (err error) 185 StopBot(mkt *mm.MarketWithHost) error 186 UpdateCEXConfig(updatedCfg *mm.CEXConfig) error 187 CEXBalance(cexName string, assetID uint32) (*libxc.ExchangeBalance, error) 188 UpdateBotConfig(updatedCfg *mm.BotConfig) error 189 RemoveBotConfig(host string, baseID, quoteID uint32) error 190 Status() *mm.Status 191 ArchivedRuns() ([]*mm.MarketMakingRun, error) 192 RunOverview(startTime int64, mkt *mm.MarketWithHost) (*mm.MarketMakingRunOverview, error) 193 RunLogs(startTime int64, mkt *mm.MarketWithHost, n uint64, refID *uint64, filter *mm.RunLogFilters) (events, updatedEvents []*mm.MarketMakingEvent, overview *mm.MarketMakingRunOverview, err error) 194 CEXBook(host string, baseID, quoteID uint32) (buys, sells []*core.MiniOrder, _ error) 195 } 196 197 // genCertPair generates a key/cert pair to the paths provided. 198 func genCertPair(certFile, keyFile string, altDNSNames []string) error { 199 log.Infof("Generating TLS certificates...") 200 201 org := "dex webserver autogenerated cert" 202 validUntil := time.Now().Add(390 * 24 * time.Hour) // https://blog.mozilla.org/security/2020/07/09/reducing-tls-certificate-lifespans-to-398-days/ 203 cert, key, err := certgen.NewTLSCertPair(elliptic.P384(), org, 204 validUntil, altDNSNames) 205 if err != nil { 206 return err 207 } 208 209 // Write cert and key files. 210 if err = os.WriteFile(certFile, cert, 0644); err != nil { 211 return err 212 } 213 if err = os.WriteFile(keyFile, key, 0600); err != nil { 214 os.Remove(certFile) 215 return err 216 } 217 218 return nil 219 } 220 221 var _ clientCore = (*core.Core)(nil) 222 223 // cachedPassword consists of the serialized crypter and an encrypted password. 224 // A key stored in the cookies is used to deserialize the crypter, then the 225 // crypter is used to decrypt the password. 226 type cachedPassword struct { 227 EncryptedPass []byte 228 SerializedCrypter []byte 229 } 230 231 type Config struct { 232 Core clientCore // *core.Core 233 MarketMaker MMCore // *mm.MarketMaker 234 Addr string 235 CustomSiteDir string 236 Language string 237 Logger dex.Logger 238 UTC bool // for stdout http request logging 239 AppVersion string // user app version for UI 240 CertFile string 241 KeyFile string 242 // NoEmbed indicates to serve files from the system disk rather than the 243 // embedded files. Since this is a developer setting, this also implies 244 // reloading of templates on each request. Note that only embedded files 245 // should be used by default since site files from older distributions may 246 // be present on the disk. When NoEmbed is true, this also implies reloading 247 // and execution of html templates on each request. 248 NoEmbed bool 249 HttpProf bool 250 } 251 252 type valStamp struct { 253 val uint64 254 stamp time.Time 255 } 256 257 // WebServer is a single-client http and websocket server enabling a browser 258 // interface to Bison Wallet. 259 type WebServer struct { 260 ctx context.Context 261 wsServer *websocket.Server 262 mux *chi.Mux 263 siteDir string 264 lang atomic.Value // string 265 langs []string 266 core clientCore 267 mm MMCore 268 addr string 269 csp string 270 srv *http.Server 271 html atomic.Value // *templates 272 273 authMtx sync.RWMutex 274 authTokens map[string]bool 275 cachedPasswords map[string]*cachedPassword // cached passwords keyed by auth token 276 277 bondBufMtx sync.Mutex 278 bondBuf map[uint32]valStamp 279 280 appVersion string 281 newAppVersionAvailable bool 282 283 useDEXBranding bool 284 } 285 286 // New is the constructor for a new WebServer. CustomSiteDir in the Config can 287 // be left blank, in which case a handful of default locations will be checked. 288 // This will work in most cases. 289 func New(cfg *Config) (*WebServer, error) { 290 log = cfg.Logger 291 292 // Only look for files on disk if NoEmbed is set. This is necessary since 293 // site files from older distributions may be present. 294 var siteDir string // empty signals embedded files only 295 if cfg.NoEmbed { 296 // Look for the "site" folder in the executable's path, the working 297 // directory, or relative to [repo root]/client/cmd/bisonw. 298 execPath, err := os.Executable() // e.g. /usr/bin/bisonw 299 if err != nil { 300 return nil, fmt.Errorf("unable to locate executable path: %w", err) 301 } 302 execPath, err = filepath.EvalSymlinks(execPath) // e.g. /opt/decred/dex/bisonw 303 if err != nil { 304 return nil, fmt.Errorf("unable to locate executable path: %w", err) 305 } 306 execPath = filepath.Dir(execPath) // e.g. /opt/decred/dex 307 308 absDir, _ := filepath.Abs(site) 309 for _, dir := range []string{ 310 cfg.CustomSiteDir, 311 filepath.Join(execPath, site), 312 absDir, 313 filepath.Clean(filepath.Join(execPath, "../../webserver/site")), 314 } { 315 if dir == "" { 316 continue 317 } 318 log.Debugf("Looking for site in %s", dir) 319 if folderExists(dir) { 320 siteDir = dir 321 break 322 } 323 } 324 325 if siteDir == "" { 326 return nil, fmt.Errorf("no HTML template files found. "+ 327 "Place the 'site' folder in the executable's directory %q or the working directory, "+ 328 "or run bisonw from within the client/cmd/bisonw source workspace folder, or specify the"+ 329 "'sitedir' configuration directive to bisonw.", execPath) 330 } 331 332 log.Infof("Located \"site\" folder at %v", siteDir) 333 } else { 334 // Developer should remember to rebuild the Go binary if they modify any 335 // frontend files, otherwise they should run with --no-embed-site. 336 log.Debugf("Using embedded site resources.") 337 } 338 339 // Create an HTTP router. 340 mux := chi.NewRouter() 341 httpServer := &http.Server{ 342 Handler: mux, 343 ReadTimeout: httpConnTimeoutSeconds * time.Second, // slow requests should not hold connections opened 344 WriteTimeout: 2 * time.Minute, // request to response time, must be long enough for slow handlers 345 } 346 347 if cfg.CertFile != "" || cfg.KeyFile != "" { 348 // Find or create the key pair. 349 keyExists := dex.FileExists(cfg.KeyFile) 350 certExists := dex.FileExists(cfg.CertFile) 351 if certExists != keyExists { 352 return nil, fmt.Errorf("missing cert pair file") 353 } 354 if !keyExists { 355 if err := genCertPair(cfg.CertFile, cfg.KeyFile, []string{cfg.Addr}); err != nil { 356 return nil, err 357 } 358 // TODO: generate a separate CA certificate. Browsers don't like 359 // that the site certificate is also a CA. 360 } 361 keyPair, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) 362 if err != nil { 363 return nil, err 364 } 365 log.Infof("Using HTTPS with certificate %v and key %v. "+ 366 "You may import the certificate as an authority (CA) in your browser, "+ 367 "or override the warning about a self-signed certificate. "+ 368 "Delete both files to regenerate them on next startup.", 369 cfg.CertFile, cfg.KeyFile) 370 httpServer.TLSConfig = &tls.Config{ 371 ServerName: cfg.Addr, 372 Certificates: []tls.Certificate{keyPair}, 373 MinVersion: tls.VersionTLS12, 374 } 375 // Uncomment to disable HTTP/2: 376 // httpServer.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0) 377 } 378 379 lang := cfg.Core.Language() 380 381 langs := make([]string, 0, len(localesMap)) 382 for l := range localesMap { 383 langs = append(langs, l) 384 } 385 386 var useDEXBranding bool 387 if xCfg := cfg.Core.ExtensionModeConfig(); xCfg != nil { 388 useDEXBranding = xCfg.UseDEXBranding 389 } 390 391 // Make the server here so its methods can be registered. 392 s := &WebServer{ 393 langs: langs, 394 core: cfg.Core, 395 mm: cfg.MarketMaker, 396 siteDir: siteDir, 397 mux: mux, 398 srv: httpServer, 399 addr: cfg.Addr, 400 wsServer: websocket.New(cfg.Core, log.SubLogger("WS")), 401 authTokens: make(map[string]bool), 402 cachedPasswords: make(map[string]*cachedPassword), 403 bondBuf: map[uint32]valStamp{}, 404 appVersion: cfg.AppVersion, 405 useDEXBranding: useDEXBranding, 406 } 407 s.lang.Store(lang) 408 409 if err := s.buildTemplates(lang); err != nil { 410 return nil, fmt.Errorf("error loading localized html templates: %v", err) 411 } 412 413 // Middleware 414 mux.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{ 415 Logger: &chiLogger{ // logs with Trace() 416 Logger: dex.StdOutLogger("MUX", log.Level(), cfg.UTC), 417 }, 418 NoColor: runtime.GOOS == "windows", 419 })) 420 mux.Use(s.securityMiddleware) 421 mux.Use(middleware.Recoverer) 422 423 // HTTP profiler 424 if cfg.HttpProf { 425 profPath := "/debug/pprof" 426 log.Infof("Mounting the HTTP profiler on %s", profPath) 427 // Option A: mount each httpprof handler directly. The caveat with this 428 // is that httpprof.Index ONLY works when mounted on /debug/pprof/. 429 // 430 // mux.Mount(profPath, http.HandlerFunc(httppprof.Index)) // also 431 // handles: goroutine, heap, threadcreate, block, allocs, mutex 432 // mux.Mount(profPath+"/cmdline", http.HandlerFunc(httppprof.Cmdline)) 433 // mux.Mount(profPath+"/profile", http.HandlerFunc(httppprof.Profile)) 434 // mux.Mount(profPath+"/symbol", http.HandlerFunc(httppprof.Symbol)) 435 // mux.Mount(profPath+"/trace", http.HandlerFunc(httppprof.Trace)) 436 437 // Option B: http pprof uses http.DefaultServeMux, so mount it: 438 mux.Mount(profPath, http.DefaultServeMux) // profPath MUST be /debug/pprof this way 439 } 440 441 // The WebSocket handler is mounted on /ws in Connect. 442 443 // Webpages 444 mux.Group(func(web chi.Router) { 445 // Inject user info for handlers that use extractUserInfo, which 446 // includes most of the page handlers that use commonArgs to 447 // inject the User object for page template execution. 448 web.Use(s.authMiddleware) 449 web.Get(settingsRoute, s.handleSettings) 450 451 web.Get("/generateqrcode", s.handleGenerateQRCode) 452 453 web.Group(func(notInit chi.Router) { 454 notInit.Use(s.requireNotInit) 455 notInit.Get(initRoute, s.handleInit) 456 }) 457 458 // The rest of the web handlers require initialization. 459 web.Group(func(webInit chi.Router) { 460 webInit.Use(s.requireInit) 461 462 webInit.Route(registerRoute, func(rr chi.Router) { 463 rr.Get("/", s.handleRegister) 464 rr.With(dexHostCtx).Get("/{host}", s.handleRegister) 465 }) 466 467 webInit.Group(func(webNoAuth chi.Router) { 468 // The login handler requires init but not auth since 469 // it performs the auth. 470 webNoAuth.Get(loginRoute, s.handleLogin) 471 472 // The rest of these handlers require both init and auth. 473 webNoAuth.Group(func(webAuth chi.Router) { 474 webAuth.Use(s.requireLogin) 475 webAuth.Get(homeRoute, s.handleHome) 476 webAuth.Get(walletsRoute, s.handleWallets) 477 webAuth.Get(walletLogRoute, s.handleWalletLogFile) 478 }) 479 }) 480 481 // Handlers requiring a DEX connection. 482 webInit.Group(func(webDC chi.Router) { 483 webDC.Use(s.requireDEXConnection, s.requireLogin) 484 webDC.With(orderIDCtx).Get("/order/{oid}", s.handleOrder) 485 webDC.Get(ordersRoute, s.handleOrders) 486 webDC.Get(exportOrderRoute, s.handleExportOrders) 487 webDC.Get(marketsRoute, s.handleMarkets) 488 webDC.Get(mmSettingsRoute, s.handleMMSettings) 489 webDC.Get(mmArchivesRoute, s.handleMMArchives) 490 webDC.Get(mmLogsRoute, s.handleMMLogs) 491 webDC.Get(marketMakerRoute, s.handleMarketMaking) 492 webDC.With(dexHostCtx).Get("/dexsettings/{host}", s.handleDexSettings) 493 }) 494 495 }) 496 }) 497 498 // api endpoints 499 mux.Route("/api", func(r chi.Router) { 500 r.Use(middleware.AllowContentType("application/json")) 501 r.Post("/init", s.apiInit) 502 r.Get("/isinitialized", s.apiIsInitialized) 503 r.Post("/resetapppassword", s.apiResetAppPassword) 504 r.Get("/user", s.apiUser) 505 r.Post("/locale", s.apiLocale) 506 r.Post("/setlocale", s.apiSetLocale) 507 r.Get("/buildinfo", s.apiBuildInfo) 508 509 r.Group(func(apiInit chi.Router) { 510 apiInit.Use(s.rejectUninited) 511 apiInit.Post("/login", s.apiLogin) 512 apiInit.Post("/getdexinfo", s.apiGetDEXInfo) // TODO: Seems unused. 513 apiInit.Post("/adddex", s.apiAddDEX) 514 apiInit.Post("/discoveracct", s.apiDiscoverAccount) 515 apiInit.Post("/bondsfeebuffer", s.apiBondsFeeBuffer) 516 }) 517 518 r.Group(func(apiAuth chi.Router) { 519 apiAuth.Use(s.rejectUnauthed) 520 apiAuth.Get("/notes", s.apiNotes) 521 apiAuth.Post("/defaultwalletcfg", s.apiDefaultWalletCfg) 522 apiAuth.Post("/postbond", s.apiPostBond) 523 apiAuth.Post("/updatebondoptions", s.apiUpdateBondOptions) 524 apiAuth.Post("/redeemprepaidbond", s.apiRedeemPrepaidBond) 525 apiAuth.Post("/newwallet", s.apiNewWallet) 526 apiAuth.Post("/openwallet", s.apiOpenWallet) 527 apiAuth.Post("/depositaddress", s.apiNewDepositAddress) 528 apiAuth.Post("/addressused", s.apiAddressUsed) 529 apiAuth.Post("/closewallet", s.apiCloseWallet) 530 apiAuth.Post("/connectwallet", s.apiConnectWallet) 531 apiAuth.Post("/rescanwallet", s.apiRescanWallet) 532 apiAuth.Post("/recoverwallet", s.apiRecoverWallet) 533 apiAuth.Post("/trade", s.apiTrade) 534 apiAuth.Post("/tradeasync", s.apiTradeAsync) 535 apiAuth.Post("/cancel", s.apiCancel) 536 apiAuth.Post("/logout", s.apiLogout) 537 apiAuth.Post("/balance", s.apiGetBalance) 538 apiAuth.Post("/parseconfig", s.apiParseConfig) 539 apiAuth.Post("/reconfigurewallet", s.apiReconfig) 540 apiAuth.Post("/changeapppass", s.apiChangeAppPass) 541 apiAuth.Post("/walletsettings", s.apiWalletSettings) 542 apiAuth.Post("/togglewalletstatus", s.apiToggleWalletStatus) 543 apiAuth.Post("/orders", s.apiOrders) 544 apiAuth.Post("/order", s.apiOrder) 545 apiAuth.Post("/send", s.apiSend) 546 apiAuth.Post("/maxbuy", s.apiMaxBuy) 547 apiAuth.Post("/maxsell", s.apiMaxSell) 548 apiAuth.Post("/preorder", s.apiPreOrder) 549 apiAuth.Post("/exportaccount", s.apiAccountExport) 550 apiAuth.Post("/exportseed", s.apiExportSeed) 551 apiAuth.Post("/importaccount", s.apiAccountImport) 552 apiAuth.Post("/toggleaccountstatus", s.apiToggleAccountStatus) 553 apiAuth.Post("/accelerateorder", s.apiAccelerateOrder) 554 apiAuth.Post("/preaccelerate", s.apiPreAccelerate) 555 apiAuth.Post("/accelerationestimate", s.apiAccelerationEstimate) 556 apiAuth.Post("/updatecert", s.apiUpdateCert) 557 apiAuth.Post("/updatedexhost", s.apiUpdateDEXHost) 558 apiAuth.Post("/restorewalletinfo", s.apiRestoreWalletInfo) 559 apiAuth.Post("/toggleratesource", s.apiToggleRateSource) 560 apiAuth.Post("/validateaddress", s.apiValidateAddress) 561 apiAuth.Post("/txfee", s.apiEstimateSendTxFee) 562 apiAuth.Post("/deletearchivedrecords", s.apiDeleteArchivedRecords) 563 apiAuth.Post("/getwalletpeers", s.apiGetWalletPeers) 564 apiAuth.Post("/addwalletpeer", s.apiAddWalletPeer) 565 apiAuth.Post("/removewalletpeer", s.apiRemoveWalletPeer) 566 apiAuth.Post("/approvetoken", s.apiApproveToken) 567 apiAuth.Post("/unapprovetoken", s.apiUnapproveToken) 568 apiAuth.Post("/approvetokenfee", s.apiApproveTokenFee) 569 apiAuth.Post("/txhistory", s.apiTxHistory) 570 apiAuth.Post("/takeaction", s.apiTakeAction) 571 apiAuth.Post("/redeemgamecode", s.redeemGameCode) 572 573 apiAuth.Post("/stakestatus", s.apiStakeStatus) 574 apiAuth.Post("/setvsp", s.apiSetVSP) 575 apiAuth.Post("/purchasetickets", s.apiPurchaseTickets) 576 apiAuth.Post("/setvotes", s.apiSetVotingPreferences) 577 apiAuth.Post("/listvsps", s.apiListVSPs) 578 apiAuth.Post("/ticketpage", s.apiTicketPage) 579 580 apiAuth.Post("/mixingstats", s.apiMixingStats) 581 apiAuth.Post("/configuremixer", s.apiConfigureMixer) 582 583 apiAuth.Post("/startmarketmakingbot", s.apiStartMarketMakingBot) 584 apiAuth.Post("/stopmarketmakingbot", s.apiStopMarketMakingBot) 585 apiAuth.Post("/updatebotconfig", s.apiUpdateBotConfig) 586 apiAuth.Post("/updatecexconfig", s.apiUpdateCEXConfig) 587 apiAuth.Post("/removebotconfig", s.apiRemoveBotConfig) 588 apiAuth.Get("/marketmakingstatus", s.apiMarketMakingStatus) 589 apiAuth.Post("/marketreport", s.apiMarketReport) 590 apiAuth.Post("/cexbalance", s.apiCEXBalance) 591 apiAuth.Get("/archivedmmruns", s.apiArchivedRuns) 592 apiAuth.Post("/mmrunlogs", s.apiRunLogs) 593 apiAuth.Post("/cexbook", s.apiCEXBook) 594 }) 595 }) 596 597 // Files 598 fileServer(mux, "/js", siteDir, "dist", "text/javascript") 599 fileServer(mux, "/css", siteDir, "dist", "text/css") 600 fileServer(mux, "/img", siteDir, "src/img", "") 601 fileServer(mux, "/font", siteDir, "src/font", "") 602 603 return s, nil 604 } 605 606 // fetchLatestVersion is a helper function to retrieve the latest version of the app 607 // from github. 608 func (w *WebServer) fetchLatestVersion(ctx context.Context) { 609 var response struct { 610 TagName string `json:"tag_name"` 611 } 612 613 err := dexnet.Get(ctx, "https://api.github.com/repos/decred/dcrdex/releases/latest", &response, dexnet.WithSizeLimit(1<<22)) 614 if err != nil { 615 log.Debugf("Error getting latest version: %v", err) 616 return 617 } 618 619 latestVersion := latestVersionRegex.FindString(response.TagName) 620 latestMajor, latestMinor, latestPatch, _, _, err := version.ParseSemVer(latestVersion) 621 if err != nil { 622 log.Debugf("Error parsing latest version: %v", err) 623 return 624 } 625 626 currentMajor, currentMinor, currentPatch, _, _, err := version.ParseSemVer(w.appVersion) 627 if err != nil { 628 log.Debugf("Error parsing app version: %v", err) 629 return 630 } 631 632 if latestMajor > currentMajor || 633 (latestMajor == currentMajor && latestMinor > currentMinor) || 634 (latestMajor == currentMajor && latestMinor == currentMinor && latestPatch > currentPatch) { 635 w.newAppVersionAvailable = true 636 } 637 } 638 639 // buildTemplates prepares the HTML templates, which are executed and served in 640 // sendTemplate. An empty siteDir indicates that the embedded templates in the 641 // htmlTmplSub FS should be used. If siteDir is set, the templates will be 642 // loaded from disk. 643 func (s *WebServer) buildTemplates(lang string) error { 644 // Try to identify language. 645 acceptLang, err := language.Parse(lang) 646 if err != nil { 647 return fmt.Errorf("unable to parse requested language: %v", err) 648 } 649 650 // Find acceptable match with available locales. 651 langTags := make([]language.Tag, 0, len(locales.Locales)) 652 localeNames := make([]string, 0, len(locales.Locales)) 653 for localeName := range locales.Locales { 654 lang, _ := language.Parse(localeName) // checked in init() 655 langTags = append(langTags, lang) 656 localeNames = append(localeNames, localeName) 657 } 658 _, idx, conf := language.NewMatcher(langTags).Match(acceptLang) 659 localeName := localeNames[idx] // use index because tag may end up as something hyper specific like zh-Hans-u-rg-cnzzzz 660 switch conf { 661 case language.Exact, language.High, language.Low: 662 log.Infof("Using language %v", localeName) 663 case language.No: 664 return fmt.Errorf("no match for %q in recognized languages %v", lang, localeNames) 665 } 666 667 var htmlDir string 668 if s.siteDir == "" { 669 log.Infof("Using embedded HTML templates") 670 } else { 671 htmlDir = filepath.Join(s.siteDir, "src", "html") 672 log.Infof("Using HTML templates in %s", htmlDir) 673 } 674 675 bb := "bodybuilder" 676 html := newTemplates(htmlDir, localeName). 677 addTemplate("login", bb, "forms"). 678 addTemplate("register", bb, "forms"). 679 addTemplate("markets", bb, "forms"). 680 addTemplate("wallets", bb, "forms"). 681 addTemplate("settings", bb, "forms"). 682 addTemplate("orders", bb). 683 addTemplate("order", bb, "forms"). 684 addTemplate("dexsettings", bb, "forms"). 685 addTemplate("init", bb). 686 addTemplate("mm", bb, "forms"). 687 addTemplate("mmsettings", bb, "forms"). 688 addTemplate("mmarchives", bb). 689 addTemplate("mmlogs", bb) 690 s.html.Store(html) 691 692 return html.buildErr() 693 } 694 695 // Addr gives the address on which WebServer is listening. Use only after 696 // Connect. 697 func (s *WebServer) Addr() string { 698 return s.addr 699 } 700 701 // Connect starts the web server. Satisfies the dex.Connector interface. 702 func (s *WebServer) Connect(ctx context.Context) (*sync.WaitGroup, error) { 703 // Start serving. 704 listener, err := net.Listen("tcp", s.addr) 705 if err != nil { 706 return nil, fmt.Errorf("Can't listen on %s. web server quitting: %w", s.addr, err) 707 } 708 https := s.srv.TLSConfig != nil 709 if https { 710 listener = tls.NewListener(listener, s.srv.TLSConfig) 711 } 712 713 s.ctx = ctx 714 715 addr, allowInCSP := prepareAddr(listener.Addr()) 716 if allowInCSP { 717 // Work around a webkit (safari) bug with the handling of the 718 // connect-src directive of content security policy. See: 719 // https://bugs.webkit.org/show_bug.cgi?id=201591. TODO: Remove this 720 // workaround since the issue has been fixed in newer versions of 721 // Safari. When this is removed, the allowInCSP variable can be removed 722 // but prepareAddr should still return 127.0.0.1 for unspecified 723 // addresses. 724 scheme := "ws" 725 if s.srv.TLSConfig != nil { 726 scheme = "wss" 727 } 728 s.csp = fmt.Sprintf("%s %s://%s", baseCSP, scheme, addr) 729 } 730 s.addr = addr 731 732 // Shutdown the server on context cancellation. 733 var wg sync.WaitGroup 734 wg.Add(1) 735 go func() { 736 defer wg.Done() 737 <-ctx.Done() 738 err := s.srv.Shutdown(context.Background()) 739 if err != nil { 740 log.Errorf("Problem shutting down rpc: %v", err) 741 } 742 }() 743 744 // Configure the websocket handler before starting the server. 745 s.mux.Get("/ws", func(w http.ResponseWriter, r *http.Request) { 746 s.wsServer.HandleConnect(ctx, w, r) 747 }) 748 749 wg.Add(1) 750 go func() { 751 defer wg.Done() 752 err = s.srv.Serve(listener) // will modify srv.TLSConfig for http/2 even when !https 753 if !errors.Is(err, http.ErrServerClosed) { 754 log.Warnf("unexpected (http.Server).Serve error: %v", err) 755 } 756 // Disconnect the websocket clients since http.(*Server).Shutdown does 757 // not deal with hijacked websocket connections. 758 s.wsServer.Shutdown() 759 log.Infof("Web server off") 760 }() 761 762 wg.Add(1) 763 go func() { 764 defer wg.Done() 765 s.readNotifications(ctx) 766 }() 767 768 wg.Add(1) 769 go func() { 770 defer wg.Done() 771 772 // Perform initial fetch of the latest version. 773 s.fetchLatestVersion(ctx) 774 775 for { 776 select { 777 case <-time.After(24 * time.Hour): 778 s.fetchLatestVersion(ctx) 779 case <-ctx.Done(): 780 return 781 } 782 } 783 }() 784 785 log.Infof("Web server listening on %s (https = %v)", s.addr, https) 786 scheme := "http" 787 if https { 788 scheme = "https" 789 } 790 fmt.Printf("\n\t**** OPEN IN YOUR BROWSER TO LOGIN AND TRADE ---> %s://%s ****\n\n", 791 scheme, s.addr) 792 return &wg, nil 793 } 794 795 // prepareAddr prepares the listening address in case a :0 was provided. 796 func prepareAddr(addr net.Addr) (string, bool) { 797 // If the IP is unspecified, default to `127.0.0.1`. This is a workaround 798 // for an issue where all ip addresses other than exactly 127.0.0.1 will 799 // always fail to match when used in CSP directives. See: 800 // https://w3c.github.io/webappsec-csp/#match-hosts. 801 defaultIP := net.IP{127, 0, 0, 1} 802 tcpAddr, ok := addr.(*net.TCPAddr) 803 if ok && (tcpAddr.IP.IsUnspecified() || tcpAddr.IP.Equal(defaultIP)) { 804 return net.JoinHostPort(defaultIP.String(), strconv.Itoa(tcpAddr.Port)), true 805 } 806 807 return addr.String(), false 808 } 809 810 // authorize creates, stores, and returns a new auth token to identify the user. 811 // deauth should be used to invalidate tokens on logout. 812 func (s *WebServer) authorize() string { 813 b := make([]byte, 32) 814 crand.Read(b) 815 token := hex.EncodeToString(b) 816 zero(b) 817 s.authMtx.Lock() 818 s.authTokens[token] = true 819 s.authMtx.Unlock() 820 return token 821 } 822 823 // deauth invalidates all current auth tokens. All existing sessions will need 824 // to login again. 825 func (s *WebServer) deauth() { 826 s.authMtx.Lock() 827 s.authTokens = make(map[string]bool) 828 s.cachedPasswords = make(map[string]*cachedPassword) 829 s.authMtx.Unlock() 830 } 831 832 // getAuthToken checks the request for an auth token cookie and returns it. 833 // An empty string is returned if there is no auth token cookie. 834 func getAuthToken(r *http.Request) string { 835 var authToken string 836 cookie, err := r.Cookie(authCK) 837 switch { 838 case err == nil: 839 authToken = cookie.Value 840 case errors.Is(err, http.ErrNoCookie): 841 default: 842 log.Errorf("authToken retrieval error: %v", err) 843 } 844 845 return authToken 846 } 847 848 // getPWKey checks the request for a password key cookie. Returns an error 849 // if it does not exist or it is not valid. 850 func getPWKey(r *http.Request) ([]byte, error) { 851 cookie, err := r.Cookie(pwKeyCK) 852 switch { 853 case err == nil: 854 sessionKey, err := hex.DecodeString(cookie.Value) 855 if err != nil { 856 return nil, err 857 } 858 return sessionKey, nil 859 case errors.Is(err, http.ErrNoCookie): 860 return nil, nil 861 default: 862 return nil, err 863 } 864 } 865 866 // isAuthed checks if the incoming request is from an authorized user/device. 867 // Requires the auth token cookie to be set in the request and for the token 868 // to match `WebServer.validAuthToken`. 869 func (s *WebServer) isAuthed(r *http.Request) bool { 870 authToken := getAuthToken(r) 871 if authToken == "" { 872 return false 873 } 874 s.authMtx.RLock() 875 defer s.authMtx.RUnlock() 876 return s.authTokens[authToken] 877 } 878 879 // getCachedPassword retrieves the cached password for the user identified by authToken and 880 // presenting the specified key in their cookies. 881 func (s *WebServer) getCachedPassword(key []byte, authToken string) ([]byte, error) { 882 s.authMtx.Lock() 883 cachedPassword, ok := s.cachedPasswords[authToken] 884 s.authMtx.Unlock() 885 if !ok { 886 return nil, fmt.Errorf("cached encrypted password not found for"+ 887 " auth token: %v", authToken) 888 } 889 890 crypter, err := encrypt.Deserialize(key, cachedPassword.SerializedCrypter) 891 if err != nil { 892 return nil, fmt.Errorf("error deserializing crypter: %w", err) 893 } 894 895 pw, err := crypter.Decrypt(cachedPassword.EncryptedPass) 896 if err != nil { 897 return nil, fmt.Errorf("error decrypting password: %w", err) 898 } 899 900 return pw, nil 901 } 902 903 // getCachedPasswordUsingRequest retrieves the cached password using the information 904 // in the request. 905 func (s *WebServer) getCachedPasswordUsingRequest(r *http.Request) ([]byte, error) { 906 authToken := getAuthToken(r) 907 if authToken == "" { 908 return nil, errNoCachedPW 909 } 910 pwKeyBlob, err := getPWKey(r) 911 if err != nil { 912 return nil, err 913 } 914 if pwKeyBlob == nil { 915 return nil, errNoCachedPW 916 } 917 return s.getCachedPassword(pwKeyBlob, authToken) 918 } 919 920 // cacheAppPassword encrypts the app password with a random encryption key and returns the key. 921 // The authToken is used to lookup the encrypted password when calling getCachedPassword. 922 func (s *WebServer) cacheAppPassword(appPW []byte, authToken string) ([]byte, error) { 923 key := encode.RandomBytes(16) 924 crypter := encrypt.NewCrypter(key) 925 defer crypter.Close() 926 encryptedPass, err := crypter.Encrypt(appPW) 927 if err != nil { 928 return nil, fmt.Errorf("error encrypting password: %v", err) 929 } 930 931 s.authMtx.Lock() 932 s.cachedPasswords[authToken] = &cachedPassword{ 933 EncryptedPass: encryptedPass, 934 SerializedCrypter: crypter.Serialize(), 935 } 936 s.authMtx.Unlock() 937 return key, nil 938 } 939 940 // isPasswordCached checks if a password can be retrieved from the encrypted 941 // password cache using the information in the request. 942 func (s *WebServer) isPasswordCached(r *http.Request) bool { 943 _, err := s.getCachedPasswordUsingRequest(r) 944 return err == nil 945 } 946 947 // readNotifications reads from the Core notification channel and relays to 948 // websocket clients. 949 func (s *WebServer) readNotifications(ctx context.Context) { 950 ch := s.core.NotificationFeed() 951 defer ch.ReturnFeed() 952 953 for { 954 select { 955 case n := <-ch.C: 956 s.wsServer.Notify(notifyRoute, n) 957 case <-ctx.Done(): 958 return 959 } 960 } 961 } 962 963 // readPost unmarshals the request body into the provided interface. 964 func readPost(w http.ResponseWriter, r *http.Request, thing any) bool { 965 body, err := io.ReadAll(r.Body) 966 r.Body.Close() 967 if err != nil { 968 log.Debugf("Error reading request body: %v", err) 969 http.Error(w, "error reading JSON message", http.StatusBadRequest) 970 return false 971 } 972 err = json.Unmarshal(body, thing) 973 if err != nil { 974 log.Debugf("failed to unmarshal JSON request: %v", err) 975 log.Debugf("raw request: %s", string(body)) 976 http.Error(w, "failed to unmarshal JSON request", http.StatusBadRequest) 977 return false 978 } 979 return true 980 } 981 982 // userInfo is information about the connected user. This type embeds the 983 // core.User type, adding fields specific to the users server authentication 984 // and cookies. 985 type userInfo struct { 986 Authed bool 987 PasswordIsCached bool 988 } 989 990 // Extract the userInfo from the request context. This should be used with 991 // authMiddleware. 992 func extractUserInfo(r *http.Request) *userInfo { 993 user, ok := r.Context().Value(ctxKeyUserInfo).(*userInfo) 994 if !ok { 995 log.Errorf("no auth info retrieved from client") 996 return &userInfo{} 997 } 998 return user 999 } 1000 1001 func serveFile(w http.ResponseWriter, r *http.Request, fullFilePath string) { 1002 // Generate the full file system path and test for existence. 1003 fi, err := os.Stat(fullFilePath) 1004 if err != nil { 1005 http.NotFound(w, r) 1006 return 1007 } 1008 1009 // Deny directory listings 1010 if fi.IsDir() { 1011 http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 1012 return 1013 } 1014 1015 http.ServeFile(w, r, fullFilePath) 1016 } 1017 1018 // fileServer is a file server for files in subDir with the parent folder 1019 // siteDire. An empty siteDir means embedded files only are served. The 1020 // pathPrefix is stripped from the request path when locating the file. 1021 func fileServer(r chi.Router, pathPrefix, siteDir, subDir, forceContentType string) { 1022 if strings.ContainsAny(pathPrefix, "{}*") { 1023 panic("FileServer does not permit URL parameters.") 1024 } 1025 1026 // Define a http.HandlerFunc to serve files but not directory indexes. 1027 hf := func(w http.ResponseWriter, r *http.Request) { 1028 // Ensure the path begins with "/". 1029 upath := r.URL.Path 1030 if strings.Contains(upath, "..") { 1031 http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 1032 return 1033 } 1034 if !strings.HasPrefix(upath, "/") { 1035 upath = "/" + upath 1036 r.URL.Path = upath 1037 } 1038 // Strip the path prefix and clean the path. 1039 upath = path.Clean(strings.TrimPrefix(upath, pathPrefix)) 1040 1041 // Deny directory listings (http.ServeFile recognizes index.html and 1042 // attempts to serve the directory contents instead). 1043 if strings.HasSuffix(upath, "/index.html") { 1044 http.NotFound(w, r) 1045 return 1046 } 1047 1048 // On Windows, a common registry misconfiguration leads to 1049 // mime.TypeByExtension setting an incorrect type for .js files, causing 1050 // the browser to refuse to execute the JavaScript. The following 1051 // workaround may be removed when Go 1.19 becomes the minimum required 1052 // version: https://go-review.googlesource.com/c/go/+/406894 1053 // https://github.com/golang/go/issues/32350 1054 if forceContentType != "" { 1055 w.Header().Set("Content-Type", forceContentType) 1056 } 1057 1058 // If siteDir is set, use system file system only. 1059 if siteDir != "" { 1060 fullFilePath := filepath.Join(siteDir, subDir, upath) 1061 serveFile(w, r, fullFilePath) 1062 return 1063 } 1064 1065 // Use the embedded files only. 1066 fs := http.FS(staticSiteRes) // so f is an http.File instead of fs.File 1067 f, err := fs.Open(path.Join(site, subDir, upath)) 1068 if err != nil { 1069 http.NotFound(w, r) 1070 return 1071 } 1072 defer f.Close() 1073 1074 // return in case it is a directory 1075 stat, err := f.Stat() 1076 if err != nil { 1077 http.Error(w, err.Error(), http.StatusInternalServerError) 1078 return 1079 } 1080 if stat.IsDir() { 1081 http.NotFound(w, r) 1082 return 1083 } 1084 1085 if forceContentType == "" { 1086 // http.ServeFile would do the following type detection. 1087 contentType := mime.TypeByExtension(filepath.Ext(upath)) 1088 if contentType == "" { 1089 // Sniff out the content type. See http.serveContent. 1090 var buf [512]byte 1091 n, _ := io.ReadFull(f, buf[:]) 1092 contentType = http.DetectContentType(buf[:n]) 1093 _, err = f.Seek(0, io.SeekStart) // rewind to output whole file 1094 if err != nil { 1095 http.Error(w, err.Error(), http.StatusInternalServerError) 1096 return 1097 } 1098 } 1099 if contentType != "" { 1100 w.Header().Set("Content-Type", contentType) 1101 } // else don't set it (plain) 1102 } 1103 1104 sendSize := stat.Size() 1105 w.Header().Set("Content-Length", strconv.FormatInt(sendSize, 10)) 1106 1107 // TODO: Set Last-Modified for the embedded files. 1108 // if modTime != nil { 1109 // w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat)) 1110 // } 1111 1112 _, err = io.CopyN(w, f, sendSize) 1113 if err != nil { 1114 log.Errorf("Writing response for path %q failed: %v", r.URL.Path, err) 1115 // Too late to write to header with error code. 1116 } 1117 } 1118 1119 // For the chi.Mux, make sure a path that ends in "/" and append a "*". 1120 muxRoot := pathPrefix 1121 if pathPrefix != "/" && pathPrefix[len(pathPrefix)-1] != '/' { 1122 r.Get(pathPrefix, http.RedirectHandler(pathPrefix+"/", 301).ServeHTTP) 1123 muxRoot += "/" 1124 } 1125 muxRoot += "*" 1126 1127 // Mount the http.HandlerFunc on the pathPrefix. 1128 r.Get(muxRoot, hf) 1129 } 1130 1131 // writeJSON marshals the provided interface and writes the bytes to the 1132 // ResponseWriter. The response code is assumed to be StatusOK. 1133 func writeJSON(w http.ResponseWriter, thing any) { 1134 writeJSONWithStatus(w, thing, http.StatusOK) 1135 } 1136 1137 // writeJSON writes marshals the provided interface and writes the bytes to the 1138 // ResponseWriter with the specified response code. 1139 func writeJSONWithStatus(w http.ResponseWriter, thing any, code int) { 1140 w.Header().Set("Content-Type", "application/json; charset=utf-8") 1141 b, err := json.Marshal(thing) 1142 if err != nil { 1143 w.WriteHeader(http.StatusInternalServerError) 1144 log.Errorf("JSON encode error: %v", err) 1145 return 1146 } 1147 w.WriteHeader(code) 1148 _, err = w.Write(append(b, byte('\n'))) 1149 if err != nil { 1150 log.Errorf("Write error: %v", err) 1151 } 1152 } 1153 1154 func folderExists(fp string) bool { 1155 stat, err := os.Stat(fp) 1156 return err == nil && stat.IsDir() 1157 } 1158 1159 // chiLogger is an adaptor around dex.Logger that satisfies 1160 // chi/middleware.LoggerInterface for chi's DefaultLogFormatter. 1161 type chiLogger struct { 1162 dex.Logger 1163 } 1164 1165 func (l *chiLogger) Print(v ...any) { 1166 l.Trace(v...) 1167 }