github.com/mysteriumnetwork/node@v0.0.0-20240516044423-365054f76801/cmd/commands/cli/command.go (about) 1 /* 2 * Copyright (C) 2017 The "MysteriumNetwork/node" Authors. 3 * 4 * This program is free software: you can redistribute it and/or modify 5 * it under the terms of the GNU General Public License as published by 6 * the Free Software Foundation, either version 3 of the License, or 7 * (at your option) any later version. 8 * 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17 18 package cli 19 20 import ( 21 "errors" 22 "flag" 23 "fmt" 24 "io" 25 stdlog "log" 26 "path/filepath" 27 "strings" 28 "time" 29 30 "github.com/anmitsu/go-shlex" 31 "github.com/chzyer/readline" 32 "github.com/rs/zerolog/log" 33 "github.com/urfave/cli/v2" 34 35 "github.com/mysteriumnetwork/node/cmd" 36 "github.com/mysteriumnetwork/node/cmd/commands/cli/clio" 37 "github.com/mysteriumnetwork/node/config" 38 "github.com/mysteriumnetwork/node/config/remote" 39 "github.com/mysteriumnetwork/node/core/connection" 40 "github.com/mysteriumnetwork/node/core/connection/connectionstate" 41 "github.com/mysteriumnetwork/node/datasize" 42 "github.com/mysteriumnetwork/node/metadata" 43 "github.com/mysteriumnetwork/node/money" 44 nattype "github.com/mysteriumnetwork/node/nat" 45 "github.com/mysteriumnetwork/node/services" 46 tequilapi_client "github.com/mysteriumnetwork/node/tequilapi/client" 47 "github.com/mysteriumnetwork/node/tequilapi/contract" 48 "github.com/mysteriumnetwork/node/utils" 49 "github.com/mysteriumnetwork/terms/terms-go" 50 ) 51 52 // CommandName is the name which is used to call this command 53 const CommandName = "cli" 54 55 const serviceHelp = `service <action> [args] 56 start <ProviderID> <ServiceType> [options] 57 stop <ServiceID> 58 status <ServiceID> 59 list 60 sessions 61 62 example: service start 0x7d5ee3557775aed0b85d691b036769c17349db23 openvpn --openvpn.port=1194 --openvpn.proto=UDP` 63 64 // NewCommand constructs CLI based Mysterium UI with possibility to control quiting 65 func NewCommand() *cli.Command { 66 return &cli.Command{ 67 Name: CommandName, 68 Usage: "Starts a CLI client with a Tequilapi", 69 Flags: []cli.Flag{&config.FlagAgreedTermsConditions, &config.FlagTequilapiAddress, &config.FlagTequilapiPort}, 70 Action: func(ctx *cli.Context) error { 71 client, err := clio.NewTequilApiClient(ctx) 72 if err != nil { 73 return err 74 } 75 76 cfg, err := remote.NewConfig(client) 77 if err != nil { 78 return err 79 } 80 81 cmdCLI := newCliApp(cfg, client) 82 83 cmd.RegisterSignalCallback(utils.SoftKiller(cmdCLI.Kill)) 84 85 return describeQuit(cmdCLI.Run(ctx)) 86 }, 87 } 88 } 89 90 func describeQuit(err error) error { 91 if err == nil || err == io.EOF || err == readline.ErrInterrupt { 92 log.Info().Msg("Stopping application") 93 return nil 94 } 95 log.Error().Err(err).Stack().Msg("Terminating application due to error") 96 return err 97 } 98 99 func newCliApp(rc *remote.Config, client *tequilapi_client.Client) *cliApp { 100 dataDir := rc.GetStringByFlag(config.FlagDataDir) 101 return &cliApp{ 102 config: rc, 103 tequilapi: client, 104 historyFile: filepath.Join(dataDir, ".cli_history"), 105 } 106 } 107 108 // cliApp describes CLI based Mysterium UI 109 type cliApp struct { 110 config *remote.Config 111 historyFile string 112 tequilapi *tequilapi_client.Client 113 fetchedProposals []contract.ProposalDTO 114 completer *readline.PrefixCompleter 115 reader *readline.Instance 116 117 currentConsumerID string 118 } 119 120 const ( 121 redColor = "\033[31m%s\033[0m" 122 identityDefaultPassphrase = "" 123 statusConnected = string(connectionstate.Connected) 124 statusNotConnected = string(connectionstate.NotConnected) 125 ) 126 127 var errTermsNotAgreed = errors.New("you must agree with provider and consumer terms of use in order to use this command") 128 129 var versionSummary = metadata.VersionAsSummary(metadata.LicenseCopyright( 130 "type 'license --warranty'", 131 "type 'license --conditions'", 132 )) 133 134 func (c *cliApp) handleTOS(ctx *cli.Context) error { 135 if ctx.Bool(config.FlagAgreedTermsConditions.Name) { 136 c.acceptTOS() 137 return nil 138 } 139 140 agreedC := c.config.GetBool(contract.TermsConsumerAgreed) 141 142 if !agreedC { 143 return errTermsNotAgreed 144 } 145 146 agreedP := c.config.GetBool(contract.TermsProviderAgreed) 147 if !agreedP { 148 return errTermsNotAgreed 149 } 150 151 version := c.config.GetString(contract.TermsVersion) 152 if version != terms.TermsVersion { 153 return fmt.Errorf("you've agreed to terms of use version %s, but version %s is required", version, terms.TermsVersion) 154 } 155 156 return nil 157 } 158 159 func (c *cliApp) acceptTOS() { 160 t := true 161 if err := c.tequilapi.UpdateTerms(contract.TermsRequest{ 162 AgreedConsumer: &t, 163 AgreedProvider: &t, 164 AgreedVersion: terms.TermsVersion, 165 }); err != nil { 166 clio.Info("Failed to save terms of use agreement, you will have to re-agree on next launch") 167 } 168 } 169 170 // Run runs CLI interface synchronously, in the same thread while blocking it 171 func (c *cliApp) Run(ctx *cli.Context) (err error) { 172 if err := c.handleTOS(ctx); err != nil { 173 clio.PrintTOSError(err) 174 return nil 175 } 176 177 c.completer = newAutocompleter(c.tequilapi, c.fetchedProposals) 178 c.fetchedProposals = c.fetchProposals() 179 180 if ctx.Args().Len() > 0 { 181 return c.handleActions(ctx.Args().Slice()) 182 } 183 184 c.reader, err = readline.NewEx(&readline.Config{ 185 Prompt: fmt.Sprintf(redColor, "ยป "), 186 HistoryFile: c.historyFile, 187 AutoComplete: c.completer, 188 InterruptPrompt: "^C", 189 EOFPrompt: "exit", 190 }) 191 if err != nil { 192 return err 193 } 194 // TODO Should overtake output of CommandRun 195 stdlog.SetOutput(c.reader.Stderr()) 196 197 for { 198 line, err := c.reader.Readline() 199 if err == readline.ErrInterrupt && len(line) > 0 { 200 continue 201 } else if err != nil { 202 c.quit() 203 return err 204 } 205 206 args, err := shlex.Split(line, true) 207 if err != nil { 208 return err 209 } 210 c.handleActions(args) 211 } 212 } 213 214 // Kill stops cli 215 func (c *cliApp) Kill() error { 216 c.reader.Clean() 217 return c.reader.Close() 218 } 219 220 func (c *cliApp) handleActions(args []string) error { 221 if len(args) == 0 { 222 return c.help() 223 } 224 cmd := strings.TrimSpace(args[0]) 225 226 cmdArgs := make([]string, 0) 227 if len(args) > 1 { 228 cmdArgs = args[1:] 229 } 230 231 staticCmds := []struct { 232 command string 233 handler func() error 234 }{ 235 {"exit", c.quit}, 236 {"quit", c.quit}, 237 {"help", c.help}, 238 {"status", c.status}, 239 {"healthcheck", c.healthcheck}, 240 {"nat", c.nodeMonitoringStatus}, 241 {"location", c.location}, 242 {"disconnect", c.disconnect}, 243 {"stop", c.stopClient}, 244 {"version", c.version}, 245 } 246 247 argCmds := []struct { 248 command string 249 handler func(args []string) error 250 }{ 251 {"connect", c.connect}, 252 {"identities", c.identities}, 253 {"orders", c.order}, 254 {"license", c.license}, 255 {"proposals", c.proposals}, 256 {"service", c.service}, 257 {"stake", c.stake}, 258 {"mmn", c.mmnApiKey}, 259 } 260 261 for _, c := range staticCmds { 262 if cmd == c.command { 263 err := c.handler() 264 if err != nil { 265 clio.Error(formatForHuman(err)) 266 } 267 return err 268 } 269 } 270 271 for _, c := range argCmds { 272 if cmd == c.command { 273 err := c.handler(cmdArgs) 274 if err != nil { 275 clio.Error(formatForHuman(err)) 276 } 277 return err 278 } 279 } 280 281 // Command matched nothing 282 return c.help() 283 } 284 285 func (c *cliApp) connect(args []string) (err error) { 286 helpMsg := "Please type in the provider identity. connect <consumer-identity> <provider-identity> <service-type> [dns=auto|provider|system|1.1.1.1] [disable-kill-switch]" 287 if len(args) < 3 { 288 clio.Info(helpMsg) 289 return errWrongArgumentCount 290 } 291 292 consumerID, providerID, serviceType := args[0], args[1], args[2] 293 migrationStatus, err := c.tequilapi.MigrateHermesStatus(consumerID) 294 if migrationStatus.Status == contract.MigrationStatusRequired { 295 clio.Infof("Hermes migration status: %s\n", migrationStatus.Status) 296 clio.Info("Migration started") 297 err := c.tequilapi.MigrateHermes(consumerID) 298 if err != nil { 299 return err 300 } 301 clio.Info("Migration finished successfully") 302 clio.Info("Try to reconnect") 303 return nil 304 } 305 306 if !services.IsTypeValid(serviceType) { 307 return fmt.Errorf("invalid service type, expected one of: %s", strings.Join(services.Types(), ",")) 308 } 309 310 var disableKillSwitch bool 311 var dns connection.DNSOption 312 313 for _, arg := range args[3:] { 314 if strings.HasPrefix(arg, "dns=") { 315 kv := strings.Split(arg, "=") 316 dns, err = connection.NewDNSOption(kv[1]) 317 if err != nil { 318 clio.Info(helpMsg) 319 return fmt.Errorf("invalid value: %w", err) 320 } 321 continue 322 } 323 switch arg { 324 case "disable-kill-switch": 325 disableKillSwitch = true 326 default: 327 clio.Info(helpMsg) 328 return errUnknownArgument 329 } 330 } 331 332 connectOptions := contract.ConnectOptions{ 333 DNS: dns, 334 DisableKillSwitch: disableKillSwitch, 335 } 336 337 clio.Status("CONNECTING", "from:", consumerID, "to:", providerID) 338 339 hermesID, err := c.config.GetHermesID() 340 if err != nil { 341 return err 342 } 343 344 // Dont throw an error here incase user identity has a password on it 345 // or we failed to randomly unlock it. We can still try to connect 346 // if identity it locked, it will notify us anyway. 347 _ = c.tequilapi.Unlock(consumerID, "") 348 349 _, err = c.tequilapi.ConnectionCreate(consumerID, providerID, hermesID, serviceType, connectOptions) 350 if err != nil { 351 return err 352 } 353 354 c.currentConsumerID = consumerID 355 356 clio.Success("Connected.") 357 return nil 358 } 359 360 func (c *cliApp) mmnApiKey(args []string) (err error) { 361 profileUrl := strings.TrimSuffix(c.config.GetStringByFlag(config.FlagMMNAddress), "/") + "/me" 362 usage := "Set MMN's API key and claim this node:\nmmn <api-key>\nTo get the token, visit: " + profileUrl + "\n" 363 364 if len(args) == 0 { 365 clio.Info(usage) 366 return 367 } 368 369 apiKey := args[0] 370 371 err = c.tequilapi.SetMMNApiKey(contract.MMNApiKeyRequest{ 372 ApiKey: apiKey, 373 }) 374 if err != nil { 375 return fmt.Errorf("failed to set MMN API key: %w", err) 376 } 377 378 clio.Success("MMN API key configured.") 379 return nil 380 } 381 382 func (c *cliApp) disconnect() (err error) { 383 err = c.tequilapi.ConnectionDestroy(0) 384 if err != nil { 385 return err 386 } 387 c.currentConsumerID = "" 388 clio.Success("Disconnected.") 389 return nil 390 } 391 392 func (c *cliApp) status() (err error) { 393 status, err := c.tequilapi.ConnectionStatus(0) 394 if err != nil { 395 clio.Warn(err) 396 } else { 397 clio.Info("Status:", status.Status) 398 clio.Info("SID:", status.SessionID) 399 } 400 401 ip, err := c.tequilapi.ConnectionIP() 402 if err != nil { 403 clio.Warn(err) 404 } else { 405 clio.Info("IP:", ip.IP) 406 } 407 408 location, err := c.tequilapi.ConnectionLocation() 409 if err != nil { 410 clio.Warn(err) 411 } else { 412 clio.Info(fmt.Sprintf("Location: %s, %s (%s - %s)", location.City, location.Country, location.IPType, location.ISP)) 413 } 414 415 if status.Status == statusConnected { 416 clio.Info("Proposal:", status.Proposal) 417 418 statistics, err := c.tequilapi.ConnectionStatistics() 419 if err != nil { 420 clio.Warn(err) 421 } else { 422 clio.Info(fmt.Sprintf("Connection duration: %s", time.Duration(statistics.Duration)*time.Second)) 423 clio.Info(fmt.Sprintf("Data: %s/%s", datasize.FromBytes(statistics.BytesReceived), datasize.FromBytes(statistics.BytesSent))) 424 clio.Info(fmt.Sprintf("Throughput: %s/%s", datasize.BitSpeed(statistics.ThroughputReceived), datasize.BitSpeed(statistics.ThroughputSent))) 425 clio.Info(fmt.Sprintf("Spent: %s", money.New(statistics.TokensSpent))) 426 } 427 } 428 return nil 429 } 430 431 func (c *cliApp) healthcheck() (err error) { 432 healthcheck, err := c.tequilapi.Healthcheck() 433 if err != nil { 434 return err 435 } 436 437 clio.Info(fmt.Sprintf("Uptime: %v", healthcheck.Uptime)) 438 clio.Info(fmt.Sprintf("Process: %v", healthcheck.Process)) 439 clio.Info(fmt.Sprintf("Version: %v", healthcheck.Version)) 440 buildString := metadata.FormatString(healthcheck.BuildInfo.Commit, healthcheck.BuildInfo.Branch, healthcheck.BuildInfo.BuildNumber) 441 clio.Info(buildString) 442 return nil 443 } 444 445 func (c *cliApp) nodeMonitoringStatus() (err error) { 446 status, err := c.tequilapi.NATStatus() 447 if err != nil { 448 return fmt.Errorf("failed to retrieve NAT traversal status: %w", err) 449 } 450 451 clio.Infof("Node Monitoring Status: %q\n", status.Status) 452 453 connStatus, err := c.tequilapi.ConnectionStatus(0) 454 if err != nil { 455 clio.Warn(err) 456 return 457 } 458 459 if connStatus.Status != statusNotConnected { 460 return nil 461 } 462 natType, err := c.tequilapi.NATType() 463 switch { 464 case err != nil: 465 clio.Warn(err) 466 case natType.Error != "": 467 clio.Warn(natType.Error) 468 default: 469 displayedNATType, ok := nattype.HumanReadableTypes[natType.Type] 470 if !ok { 471 displayedNATType = string(natType.Type) 472 } 473 clio.Info("NAT type:", displayedNATType) 474 } 475 476 return nil 477 } 478 479 func (c *cliApp) proposals(args []string) (err error) { 480 proposals := c.fetchProposals() 481 c.fetchedProposals = proposals 482 483 filter := "" 484 if len(args) > 0 { 485 filter = strings.Join(args, " ") 486 } 487 filterMsg := "" 488 if filter != "" { 489 filterMsg = fmt.Sprintf("(filter: '%s')", filter) 490 } 491 clio.Info(fmt.Sprintf("Found %v proposals %s", len(proposals), filterMsg)) 492 493 for _, proposal := range proposals { 494 country := proposal.Location.Country 495 if country == "" { 496 country = "Unknown" 497 } 498 499 var policies []string 500 if proposal.AccessPolicies != nil { 501 for _, policy := range *proposal.AccessPolicies { 502 policies = append(policies, policy.ID) 503 } 504 } 505 506 msg := fmt.Sprintf("- provider id: %v\ttype: %v\tcountry: %v\taccess policies: %v\tprovider type: %v", proposal.ProviderID, proposal.ServiceType, country, strings.Join(policies, ","), proposal.Location.IPType) 507 508 if filter == "" || 509 strings.Contains(proposal.ProviderID, filter) || 510 strings.Contains(country, filter) { 511 clio.Info(msg) 512 } 513 } 514 515 return nil 516 } 517 518 func (c *cliApp) fetchProposals() []contract.ProposalDTO { 519 proposals, err := c.tequilapi.ProposalsNATCompatible() 520 if err != nil { 521 clio.Warn(err) 522 return []contract.ProposalDTO{} 523 } 524 return proposals 525 } 526 527 func (c *cliApp) location() (err error) { 528 location, err := c.tequilapi.OriginLocation() 529 if err != nil { 530 return err 531 } 532 533 clio.Info(fmt.Sprintf("Location: %s, %s (%s - %s)", location.City, location.Country, location.IPType, location.ISP)) 534 return nil 535 } 536 537 func (c *cliApp) help() (err error) { 538 clio.Info("Mysterium CLI commands:") 539 fmt.Println(c.completer.Tree(" ")) 540 return nil 541 } 542 543 // quit stops cli and client commands and exits application 544 func (c *cliApp) quit() (err error) { 545 stop := utils.SoftKiller(c.Kill) 546 stop() 547 return nil 548 } 549 550 func (c *cliApp) stopClient() (err error) { 551 err = c.tequilapi.Stop() 552 if err != nil { 553 return fmt.Errorf("cannot stop the client: %w", err) 554 } 555 clio.Success("Client stopped") 556 return nil 557 } 558 559 func (c *cliApp) version() (err error) { 560 fmt.Println(versionSummary) 561 return nil 562 } 563 564 func (c *cliApp) license(args []string) (err error) { 565 arg := "" 566 if len(args) > 0 { 567 arg = args[0] 568 } 569 if arg == "warranty" { 570 fmt.Print(metadata.LicenseWarranty) 571 } else if arg == "conditions" { 572 fmt.Print(metadata.LicenseConditions) 573 } else { 574 clio.Info("identities command:\n warranty\n conditions") 575 } 576 return nil 577 } 578 579 func getIdentityOptionList(tequilapi *tequilapi_client.Client) func(string) []string { 580 return func(line string) []string { 581 var identities []string 582 ids, err := tequilapi.GetIdentities() 583 if err != nil { 584 clio.Warn(err) 585 return identities 586 } 587 for _, id := range ids { 588 identities = append(identities, id.Address) 589 } 590 591 return identities 592 } 593 } 594 595 func getProposalOptionList(proposals []contract.ProposalDTO) func(string) []string { 596 return func(line string) []string { 597 var providerIDS []string 598 for _, proposal := range proposals { 599 providerIDS = append(providerIDS, proposal.ProviderID) 600 } 601 return providerIDS 602 } 603 } 604 605 func newAutocompleter(tequilapi *tequilapi_client.Client, proposals []contract.ProposalDTO) *readline.PrefixCompleter { 606 connectOpts := []readline.PrefixCompleterInterface{ 607 readline.PcItem("dns=auto"), 608 readline.PcItem("dns=provider"), 609 readline.PcItem("dns=system"), 610 readline.PcItem("dns=1.1.1.1"), 611 } 612 return readline.NewPrefixCompleter( 613 readline.PcItem( 614 "connect", 615 readline.PcItemDynamic( 616 getIdentityOptionList(tequilapi), 617 readline.PcItemDynamic( 618 getProposalOptionList(proposals), 619 readline.PcItem("noop", connectOpts...), 620 readline.PcItem("openvpn", connectOpts...), 621 readline.PcItem("wireguard", connectOpts...), 622 ), 623 ), 624 ), 625 readline.PcItem( 626 "service", 627 readline.PcItem("start", readline.PcItemDynamic( 628 getIdentityOptionList(tequilapi), 629 readline.PcItem("noop"), 630 readline.PcItem("openvpn"), 631 readline.PcItem("wireguard"), 632 )), 633 readline.PcItem("stop"), 634 readline.PcItem("list"), 635 readline.PcItem("status"), 636 readline.PcItem("sessions"), 637 ), 638 readline.PcItem( 639 "identities", 640 readline.PcItem("list"), 641 readline.PcItem("get", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 642 readline.PcItem("balance", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 643 readline.PcItem("new"), 644 readline.PcItem("unlock", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 645 readline.PcItem("register", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 646 readline.PcItem("beneficiary-status", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 647 readline.PcItem("beneficiary-set", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 648 readline.PcItem("settle", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 649 readline.PcItem("referralcode", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 650 readline.PcItem("export", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 651 readline.PcItem("import"), 652 readline.PcItem("withdraw", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 653 readline.PcItem("last-withdrawal", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 654 readline.PcItem("migrate-hermes", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 655 readline.PcItem("migrate-hermes-status", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 656 ), 657 readline.PcItem("status"), 658 readline.PcItem( 659 "stake", 660 readline.PcItem("increase"), 661 readline.PcItem("decrease"), 662 ), 663 readline.PcItem("orders", 664 readline.PcItem("create", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 665 readline.PcItem("get", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 666 readline.PcItem("get-all", readline.PcItemDynamic(getIdentityOptionList(tequilapi))), 667 readline.PcItem("gateways"), 668 ), 669 readline.PcItem("healthcheck"), 670 readline.PcItem("nat"), 671 readline.PcItem("proposals"), 672 readline.PcItem("location"), 673 readline.PcItem("disconnect"), 674 readline.PcItem("mmn"), 675 readline.PcItem("help"), 676 readline.PcItem("quit"), 677 readline.PcItem("stop"), 678 readline.PcItem( 679 "license", 680 readline.PcItem("warranty"), 681 readline.PcItem("conditions"), 682 ), 683 ) 684 } 685 686 func parseStartFlags(serviceType string, args ...string) (services.StartOptions, error) { 687 var flags []cli.Flag 688 config.RegisterFlagsServiceStart(&flags) 689 config.RegisterFlagsServiceOpenvpn(&flags) 690 config.RegisterFlagsServiceWireguard(&flags) 691 config.RegisterFlagsServiceNoop(&flags) 692 693 set := flag.NewFlagSet("", flag.ContinueOnError) 694 for _, f := range flags { 695 f.Apply(set) 696 } 697 if err := set.Parse(args); err != nil { 698 return services.StartOptions{}, err 699 } 700 701 ctx := cli.NewContext(nil, set, nil) 702 config.ParseFlagsServiceStart(ctx) 703 config.ParseFlagsServiceOpenvpn(ctx) 704 config.ParseFlagsServiceWireguard(ctx) 705 config.ParseFlagsServiceNoop(ctx) 706 707 return services.GetStartOptions(serviceType) 708 }