|
9 | 9 | import pkgutil |
10 | 10 | import re |
11 | 11 | import subprocess # nosec |
| 12 | +import time |
12 | 13 | import uuid |
13 | 14 | from enum import Enum |
14 | 15 | from shutil import which |
|
34 | 35 |
|
35 | 36 | from .__version__ import __version__ |
36 | 37 | from .azcopy import azcopy_sync |
37 | | -from .backend import Backend, BackendConfig, ContainerWrapper |
| 38 | +from .backend import Backend, BackendConfig, ContainerWrapper, wait |
| 39 | +from .ssh import build_ssh_command, ssh_connect, temp_file |
38 | 40 |
|
39 | 41 | UUID_EXPANSION = TypeVar("UUID_EXPANSION", UUID, str) |
40 | 42 |
|
@@ -529,18 +531,29 @@ def _download_tasks( |
529 | 531 |
|
530 | 532 |
|
531 | 533 | class Repro(Endpoint): |
532 | | - """Interact with repro files""" |
| 534 | + """Interact with Reproduction VMs""" |
533 | 535 |
|
534 | 536 | endpoint = "repro_vms" |
535 | 537 |
|
| 538 | + def get(self, vm_id: UUID_EXPANSION) -> models.Repro: |
| 539 | + """get information about a Reproduction VM""" |
| 540 | + vm_id_expanded = self._disambiguate_uuid( |
| 541 | + "vm_id", vm_id, lambda: [str(x.vm_id) for x in self.list()] |
| 542 | + ) |
| 543 | + |
| 544 | + self.logger.debug("get repro vm: %s", vm_id_expanded) |
| 545 | + return self._req_model( |
| 546 | + "GET", models.Repro, data=requests.ReproGet(vm_id=vm_id_expanded) |
| 547 | + ) |
| 548 | + |
536 | 549 | def get_files( |
537 | 550 | self, |
538 | 551 | report_container: primitives.Container, |
539 | 552 | report_name: str, |
540 | 553 | include_setup: bool = False, |
541 | 554 | output_dir: primitives.Directory = primitives.Directory("."), |
542 | 555 | ) -> None: |
543 | | - """downloads the files necessary to locally repro the crash from given report""" |
| 556 | + """downloads the files necessary to locally repro the crash from a given report""" |
544 | 557 | report_bytes = self.onefuzz.containers.files.get(report_container, report_name) |
545 | 558 | report = json.loads(report_bytes) |
546 | 559 |
|
@@ -602,6 +615,230 @@ def get_files( |
602 | 615 | primitives.Container(setup_container), output_dir |
603 | 616 | ) |
604 | 617 |
|
| 618 | + def create( |
| 619 | + self, container: primitives.Container, path: str, duration: int = 24 |
| 620 | + ) -> models.Repro: |
| 621 | + """Create a Reproduction VM from a Crash Report""" |
| 622 | + self.logger.info( |
| 623 | + "creating repro vm: %s %s (%d hours)", container, path, duration |
| 624 | + ) |
| 625 | + return self._req_model( |
| 626 | + "POST", |
| 627 | + models.Repro, |
| 628 | + data=models.ReproConfig(container=container, path=path, duration=duration), |
| 629 | + ) |
| 630 | + |
| 631 | + def delete(self, vm_id: UUID_EXPANSION) -> models.Repro: |
| 632 | + """Delete a Reproduction VM""" |
| 633 | + vm_id_expanded = self._disambiguate_uuid( |
| 634 | + "vm_id", vm_id, lambda: [str(x.vm_id) for x in self.list()] |
| 635 | + ) |
| 636 | + |
| 637 | + self.logger.debug("deleting repro vm: %s", vm_id_expanded) |
| 638 | + return self._req_model( |
| 639 | + "DELETE", models.Repro, data=requests.ReproGet(vm_id=vm_id_expanded) |
| 640 | + ) |
| 641 | + |
| 642 | + def list(self) -> List[models.Repro]: |
| 643 | + """List all VMs""" |
| 644 | + self.logger.debug("listing repro vms") |
| 645 | + return self._req_model_list("GET", models.Repro, data=requests.ReproGet()) |
| 646 | + |
| 647 | + def _dbg_linux( |
| 648 | + self, repro: models.Repro, debug_command: Optional[str] |
| 649 | + ) -> Optional[str]: |
| 650 | + """Launch gdb with GDB script that includes 'target remote | ssh ...'""" |
| 651 | + |
| 652 | + if ( |
| 653 | + repro.auth is None |
| 654 | + or repro.ip is None |
| 655 | + or repro.state != enums.VmState.running |
| 656 | + ): |
| 657 | + raise Exception("vm setup failed: %s" % repro.state) |
| 658 | + |
| 659 | + with build_ssh_command( |
| 660 | + repro.ip, repro.auth.private_key, command="-T" |
| 661 | + ) as ssh_cmd: |
| 662 | + gdb_script = [ |
| 663 | + "target remote | %s sudo /onefuzz/bin/repro-stdout.sh" |
| 664 | + % " ".join(ssh_cmd) |
| 665 | + ] |
| 666 | + |
| 667 | + if debug_command: |
| 668 | + gdb_script += [debug_command, "quit"] |
| 669 | + |
| 670 | + with temp_file("gdb.script", "\n".join(gdb_script)) as gdb_script_path: |
| 671 | + dbg = ["gdb", "--silent", "--command", gdb_script_path] |
| 672 | + |
| 673 | + if debug_command: |
| 674 | + dbg += ["--batch"] |
| 675 | + |
| 676 | + try: |
| 677 | + # security note: dbg is built from content coming from |
| 678 | + # the server, which is trusted in this context. |
| 679 | + return subprocess.run( # nosec |
| 680 | + dbg, stdout=subprocess.PIPE, stderr=subprocess.STDOUT |
| 681 | + ).stdout.decode(errors="ignore") |
| 682 | + except subprocess.CalledProcessError as err: |
| 683 | + self.logger.error( |
| 684 | + "debug failed: %s", err.output.decode(errors="ignore") |
| 685 | + ) |
| 686 | + raise err |
| 687 | + else: |
| 688 | + # security note: dbg is built from content coming from the |
| 689 | + # server, which is trusted in this context. |
| 690 | + subprocess.call(dbg) # nosec |
| 691 | + return None |
| 692 | + |
| 693 | + def _dbg_windows( |
| 694 | + self, |
| 695 | + repro: models.Repro, |
| 696 | + debug_command: Optional[str], |
| 697 | + retry_limit: Optional[int], |
| 698 | + ) -> Optional[str]: |
| 699 | + """Setup an SSH tunnel, then connect via CDB over SSH tunnel""" |
| 700 | + |
| 701 | + if ( |
| 702 | + repro.auth is None |
| 703 | + or repro.ip is None |
| 704 | + or repro.state != enums.VmState.running |
| 705 | + ): |
| 706 | + raise Exception("vm setup failed: %s" % repro.state) |
| 707 | + |
| 708 | + retry_count = 0 |
| 709 | + bind_all = which("wslpath") is not None and repro.os == enums.OS.windows |
| 710 | + proxy = "*:" + REPRO_SSH_FORWARD if bind_all else REPRO_SSH_FORWARD |
| 711 | + while retry_limit is None or retry_count <= retry_limit: |
| 712 | + if retry_limit: |
| 713 | + retry_count = retry_count + 1 |
| 714 | + with ssh_connect(repro.ip, repro.auth.private_key, proxy=proxy): |
| 715 | + dbg = ["cdb.exe", "-remote", "tcp:port=1337,server=localhost"] |
| 716 | + if debug_command: |
| 717 | + dbg_script = [debug_command, "qq"] |
| 718 | + with temp_file( |
| 719 | + "db.script", "\r\n".join(dbg_script) |
| 720 | + ) as dbg_script_path: |
| 721 | + dbg += ["-cf", _wsl_path(dbg_script_path)] |
| 722 | + |
| 723 | + logging.debug("launching: %s", dbg) |
| 724 | + try: |
| 725 | + # security note: dbg is built from content coming from the server, |
| 726 | + # which is trusted in this context. |
| 727 | + return subprocess.run( # nosec |
| 728 | + dbg, stdout=subprocess.PIPE, stderr=subprocess.STDOUT |
| 729 | + ).stdout.decode(errors="ignore") |
| 730 | + except subprocess.CalledProcessError as err: |
| 731 | + if err.returncode == 0x8007274D: |
| 732 | + self.logger.info( |
| 733 | + "failed to connect to debug-server trying again in 10 seconds..." |
| 734 | + ) |
| 735 | + time.sleep(10.0) |
| 736 | + else: |
| 737 | + self.logger.error( |
| 738 | + "debug failed: %s", |
| 739 | + err.output.decode(errors="ignore"), |
| 740 | + ) |
| 741 | + raise err |
| 742 | + else: |
| 743 | + logging.debug("launching: %s", dbg) |
| 744 | + # security note: dbg is built from content coming from the |
| 745 | + # server, which is trusted in this context. |
| 746 | + try: |
| 747 | + subprocess.check_call(dbg) # nosec |
| 748 | + return None |
| 749 | + except subprocess.CalledProcessError as err: |
| 750 | + if err.returncode == 0x8007274D: |
| 751 | + self.logger.info( |
| 752 | + "failed to connect to debug-server trying again in 10 seconds..." |
| 753 | + ) |
| 754 | + time.sleep(10.0) |
| 755 | + else: |
| 756 | + return None |
| 757 | + |
| 758 | + if retry_limit is not None: |
| 759 | + self.logger.info( |
| 760 | + f"failed to connect to debug-server after {retry_limit} attempts. Please try again later " |
| 761 | + + f"with onefuzz debug connect {repro.vm_id}" |
| 762 | + ) |
| 763 | + return None |
| 764 | + |
| 765 | + def connect( |
| 766 | + self, |
| 767 | + vm_id: UUID_EXPANSION, |
| 768 | + delete_after_use: bool = False, |
| 769 | + debug_command: Optional[str] = None, |
| 770 | + retry_limit: Optional[int] = None, |
| 771 | + ) -> Optional[str]: |
| 772 | + """Connect to an existing Reproduction VM""" |
| 773 | + |
| 774 | + self.logger.info("connecting to reproduction VM: %s", vm_id) |
| 775 | + |
| 776 | + if which("ssh") is None: |
| 777 | + raise Exception("unable to find ssh on local machine") |
| 778 | + |
| 779 | + def missing_os() -> Tuple[bool, str, models.Repro]: |
| 780 | + repro = self.get(vm_id) |
| 781 | + return ( |
| 782 | + repro.os is not None, |
| 783 | + "waiting for os determination", |
| 784 | + repro, |
| 785 | + ) |
| 786 | + |
| 787 | + repro = wait(missing_os) |
| 788 | + |
| 789 | + if repro.os == enums.OS.windows: |
| 790 | + if which("cdb.exe") is None: |
| 791 | + raise Exception("unable to find cdb.exe on local machine") |
| 792 | + if repro.os == enums.OS.linux: |
| 793 | + if which("gdb") is None: |
| 794 | + raise Exception("unable to find gdb on local machine") |
| 795 | + |
| 796 | + def func() -> Tuple[bool, str, models.Repro]: |
| 797 | + repro = self.get(vm_id) |
| 798 | + state = repro.state |
| 799 | + return ( |
| 800 | + repro.auth is not None |
| 801 | + and repro.ip is not None |
| 802 | + and state not in [enums.VmState.init, enums.VmState.extensions_launch], |
| 803 | + "launching reproducing vm. current state: %s" % state, |
| 804 | + repro, |
| 805 | + ) |
| 806 | + |
| 807 | + repro = wait(func) |
| 808 | + # give time for debug server to initialize |
| 809 | + time.sleep(30.0) |
| 810 | + result: Optional[str] = None |
| 811 | + if repro.os == enums.OS.windows: |
| 812 | + result = self._dbg_windows(repro, debug_command, retry_limit) |
| 813 | + elif repro.os == enums.OS.linux: |
| 814 | + result = self._dbg_linux(repro, debug_command) |
| 815 | + else: |
| 816 | + raise NotImplementedError |
| 817 | + |
| 818 | + if delete_after_use: |
| 819 | + self.logger.debug("deleting vm %s", repro.vm_id) |
| 820 | + self.delete(repro.vm_id) |
| 821 | + |
| 822 | + return result |
| 823 | + |
| 824 | + def create_and_connect( |
| 825 | + self, |
| 826 | + container: primitives.Container, |
| 827 | + path: str, |
| 828 | + duration: int = 24, |
| 829 | + delete_after_use: bool = False, |
| 830 | + debug_command: Optional[str] = None, |
| 831 | + retry_limit: Optional[int] = None, |
| 832 | + ) -> Optional[str]: |
| 833 | + """Create and connect to a Reproduction VM""" |
| 834 | + repro = self.create(container, path, duration=duration) |
| 835 | + return self.connect( |
| 836 | + repro.vm_id, |
| 837 | + delete_after_use=delete_after_use, |
| 838 | + debug_command=debug_command, |
| 839 | + retry_limit=retry_limit, |
| 840 | + ) |
| 841 | + |
605 | 842 |
|
606 | 843 | class Notifications(Endpoint): |
607 | 844 | """Interact with models.Notifications""" |
|
0 commit comments