diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52113ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build UI + run: bun run src/ui/build.ts + + - name: Type check + run: bunx tsc --noEmit + + - name: Lint + run: bunx biome check src/ + + - name: Build binary + run: bun build src/cli.ts --compile --outfile katana + + - name: Test binary + run: | + chmod +x katana + ./katana --version + ./katana --help diff --git a/.github/workflows/full-tests.yml b/.github/workflows/full-tests.yml deleted file mode 100644 index 8f0f0e8..0000000 --- a/.github/workflows/full-tests.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Full Test Suite - -on: - push: - paths: - - '**.py' - - 'requirements.txt' - - 'setup.py' - - '.github/workflows/**' - - 'test/**' - branches: [ main ] - pull_request: - paths: - - '**.py' - - 'requirements.txt' - - 'setup.py' - - '.github/workflows/**' - - 'test/**' - branches: [ main ] - -jobs: - collect-all-tests: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - - id: set-matrix - run: | - # Get all test scripts except test-all.sh - TESTS=$(ls test/test-*.sh | grep -v test-all.sh | sed 's/.*test-\(.*\)\.sh/\1/' | jq -R -s -c 'split("\n")[:-1]') - echo "matrix={\"package\":$TESTS}" >> $GITHUB_OUTPUT - - run-tests: - needs: collect-all-tests - uses: ./.github/workflows/package-test.yaml - strategy: - matrix: ${{fromJson(needs.collect-all-tests.outputs.matrix)}} - fail-fast: false # Continue running other tests even if one fails - max-parallel: 6 # Limit parallel jobs to avoid resource constraints - with: - package-name: ${{ matrix.package }} diff --git a/.github/workflows/module-tests.yml b/.github/workflows/module-tests.yml deleted file mode 100644 index 23223bf..0000000 --- a/.github/workflows/module-tests.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Module Tests - -on: - push: - paths: - - 'modules/**' - branches: [ main ] - pull_request: - paths: - - 'modules/**' - branches: [ main ] - -jobs: - check-full-tests: - runs-on: ubuntu-latest - outputs: - should-run: ${{ steps.check.outputs.should_run }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: check - run: | - # Check if any paths that trigger full tests were changed - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - FULL_TEST_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.py$|requirements.txt|setup.py|\.github/workflows/|test/' || true) - else - FULL_TEST_CHANGES=$(git diff --name-only HEAD^ HEAD | grep -E '\.py$|requirements.txt|setup.py|\.github/workflows/|test/' || true) - fi - - if [ -z "$FULL_TEST_CHANGES" ]; then - echo "Full test suite not triggered, should run module tests" - echo "should_run=true" >> $GITHUB_OUTPUT - else - echo "Full test suite will run, skipping module tests" - echo "should_run=false" >> $GITHUB_OUTPUT - fi - - determine-tests: - needs: check-full-tests - if: needs.check-full-tests.outputs.should-run == 'true' - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all history for comparing changes - - - id: set-matrix - run: | - # Get changed files in modules directory - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^modules/' || true) - else - CHANGED_FILES=$(git diff --name-only HEAD^ HEAD | grep '^modules/' || true) - fi - - # Extract specific tool/target names from paths - # Example: modules/tools/zap.yml -> zap - COMPONENTS=$(echo "$CHANGED_FILES" | sed -n 's|^modules/[^/]*/\([^/]*\)\..*|\1|p' | sort -u) - - # Build matrix JSON - PACKAGES="[" - FIRST=true - for COMPONENT in $COMPONENTS; do - if [ -f "test/test-${COMPONENT}.sh" ]; then - if [ "$FIRST" = true ]; then - FIRST=false - else - PACKAGES="$PACKAGES," - fi - PACKAGES="$PACKAGES\"$COMPONENT\"" - fi - done - PACKAGES="$PACKAGES]" - - # If no valid test files found, run all tests - if [ "$PACKAGES" = "[]" ]; then - PACKAGES='["all"]' - fi - - echo "matrix={\"package\":$PACKAGES}" >> $GITHUB_OUTPUT - - run-tests: - needs: [check-full-tests, determine-tests] - if: needs.check-full-tests.outputs.should-run == 'true' - uses: ./.github/workflows/package-test.yaml - strategy: - matrix: ${{fromJson(needs.determine-tests.outputs.matrix)}} - fail-fast: false - with: - package-name: ${{ matrix.package }} diff --git a/.github/workflows/package-test.yaml b/.github/workflows/package-test.yaml deleted file mode 100644 index c6acc8a..0000000 --- a/.github/workflows/package-test.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: Reusable workflow for testing packages - -on: - workflow_call: - inputs: - package-name: - required: true - type: string - ubuntu-version: - required: false - type: string - default: '22.04' - -jobs: - test: - runs-on: ubuntu-${{ inputs.ubuntu-version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Setup test environment - run: sudo ./test/provision-ubuntu.sh - - - name: Make test script executable - run: sudo chmod +x ./test/test-${{ inputs.package-name }}.sh - - - name: Run tests - run: sudo ./test/test-${{ inputs.package-name }}.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d1b2da --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build UI + run: bun run src/ui/build.ts + + - name: Build binary for Linux + run: | + bun build src/cli.ts --compile --target=bun-linux-x64 --outfile katana-linux-x64 + chmod +x katana-linux-x64 + + - name: Test binary + run: | + ./katana-linux-x64 --version + ./katana-linux-x64 --help + + - name: Extract version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Extract changelog for this version + id: changelog + run: | + if [ -f CHANGELOG.md ]; then + # Extract changelog section for this version + awk '/^## \[${{ steps.get_version.outputs.VERSION }}\]/{flag=1;next}/^## \[/{flag=0}flag' CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "Release ${{ steps.get_version.outputs.VERSION }}" > release_notes.md + echo "" >> release_notes.md + echo "See [CHANGELOG.md](CHANGELOG.md) for details." >> release_notes.md + fi + else + echo "Release ${{ steps.get_version.outputs.VERSION }}" > release_notes.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + name: Katana ${{ steps.get_version.outputs.VERSION }} + body_path: release_notes.md + files: | + katana-linux-x64 + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ac1f3ec..5da6078 100644 --- a/.gitignore +++ b/.gitignore @@ -1,217 +1,46 @@ -# Created by .ignore support plugin (hsz.mobi) -### Python template -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Preliminary design documentation (private) +mvp-docs/ -# C extensions -*.so +# AI instructions (private) +CLAUDE.md +.claude/ -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ +# Dependencies +node_modules/ -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid +# Build output +bin/ +dist/ +*.exe +src/ui/embedded-assets.ts -# SageMath parsed files -*.sage.py +# Bun +.bun/ +bun.lockb -# Environments +# Environment .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site +.env.local -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml +# Vagrant +.vagrant/ -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ -# Editor-based Rest Client -.idea/httpRequests +# OS +.DS_Store +Thumbs.db -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser +# Runtime data +*.log +logs/ -.idea/* -*.iml -.vagrant/ +# Katana runtime (for testing) +.local/ +.CLAUDE_LAST_SESSION.md +.playwright-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9bd071 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to Katana will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-01-06 + +### Added + +- **Complete rewrite** of Katana using Bun/TypeScript +- **Single executable** distribution - no runtime dependencies +- **Built-in reverse proxy** with hostname-based routing +- **Web dashboard** for managing targets +- **Self-signed CA** with browser-exportable certificate +- **Docker Compose** based target deployment +- **Health check system** (`katana doctor`) + +### Target Modules + +- DVWA (Damn Vulnerable Web Application) +- OWASP Juice Shop +- SamuraiWTF Dojo Basic Lite +- SamuraiWTF Dojo Scavenger Lite +- DVGA (Damn Vulnerable GraphQL Application) +- OWASP WrongSecrets +- Musashi.js (CORS, CSP, JWT demos) + +### CLI Commands + +- `install`, `remove`, `start`, `stop` - Target lifecycle +- `status`, `list`, `logs` - Information commands +- `lock`, `unlock` - System locking +- `cert init`, `cert renew`, `cert export`, `cert status` - Certificate management +- `dns sync`, `dns list` - DNS management +- `proxy start`, `proxy status` - Proxy management +- `doctor` - Health checks +- `cleanup` - Resource cleanup +- `setup-proxy` - Initial setup + +### Changed + +- Replaced Python implementation with Bun/TypeScript +- Replaced custom plugin system with Docker Compose +- Moved from port 8443 to standard HTTPS port 443 +- No longer requires running as root (uses setcap) + +### Removed + +- Python-based architecture +- Ansible-like module configuration +- CherryPy web server + +[2.0.0]: https://github.com/SamuraiWTF/katana/releases/tag/v2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7e4caf4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,145 @@ +# Contributing to Katana + +Thank you for your interest in contributing to Katana! This document provides guidelines for contributing to the project. + +## Code of Conduct + +This project follows the [OWASP Code of Conduct](https://owasp.org/www-policy/operational/code-of-conduct). Please be respectful and constructive in all interactions. + +## Ways to Contribute + +### Bug Reports + +Found a bug? Please [open an issue](https://github.com/SamuraiWTF/katana/issues/new?template=bug_report.md) with: + +- Clear description of the issue +- Steps to reproduce +- Expected vs actual behavior +- Output from `katana doctor` +- Your Linux distribution and version + +### Feature Requests + +Have an idea? [Open an issue](https://github.com/SamuraiWTF/katana/issues/new?template=feature_request.md) describing: + +- The problem you're trying to solve +- Your proposed solution +- Alternative approaches you considered + +### New Target Modules + +Want to add a vulnerable application? See the [Module Development Guide](docs/module-development.md) for: + +- Module structure requirements +- Testing your module +- Submitting a pull request + +### Documentation Improvements + +Documentation PRs are always welcome! This includes: + +- Fixing typos and errors +- Clarifying instructions +- Adding examples +- Translating documentation + +### Code Contributions + +For code changes, please: + +1. Open an issue first to discuss the change +2. Fork the repository +3. Create a feature branch +4. Make your changes +5. Submit a pull request + +## Development Setup + +See the [Development Guide](docs/development-guide.md) for detailed setup instructions. + +Quick start: + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/katana.git +cd katana + +# Install dependencies +bun install + +# Run from source +bun run src/cli.ts --help +``` + +## Pull Request Process + +### Before Submitting + +1. **Create an issue** (unless fixing a typo or obvious bug) +2. **Fork and branch** from `main` +3. **Make changes** following our code style +4. **Test your changes:** + ```bash + bunx tsc --noEmit # Type checking + bunx biome check src/ # Linting + ./tests/e2e/run-all.sh # E2E tests + ``` +5. **Update documentation** if needed + +### PR Requirements + +- Clear description of changes +- Link to related issue(s) +- Passes all CI checks +- Includes tests for new functionality +- Documentation updated if applicable + +### Review Process + +1. Maintainers will review your PR +2. Address any feedback +3. Once approved, a maintainer will merge + +## Code Style + +We use [Biome](https://biomejs.dev/) for linting and formatting: + +```bash +# Check code +bunx biome check src/ + +# Auto-fix issues +bunx biome check --apply src/ + +# Format code +bunx biome format --write src/ +``` + +### TypeScript Guidelines + +- Use strict TypeScript +- Prefer explicit types for public APIs +- Use Zod for runtime validation +- Add JSDoc comments for public functions + +### Commit Messages + +Write clear, descriptive commit messages: + +``` +Add certificate expiration warning to status command + +- Check cert expiration when showing status +- Display warning if expiring within 30 days +- Include renewal instructions in warning +``` + +## Questions? + +- Check existing [issues](https://github.com/SamuraiWTF/katana/issues) +- Read the [documentation](docs/) +- Open a new issue for questions + +## License + +By contributing, you agree that your contributions will be licensed under the Apache License 2.0. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 032eba3..0000000 --- a/Pipfile +++ /dev/null @@ -1,16 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -docker = "*" -requests = "*" -CherryPy = "*" -PyYAML = "*" -GitPython = "*" - -[dev-packages] - -[requires] -# python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index d3f4a58..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,218 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "4cf989a6b1d8934101076408d0b33300ae8eaffee0adb88d78a7b4ae9b5468f9" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" - ], - "version": "==2020.12.5" - }, - "chardet": { - "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" - }, - "cheroot": { - "hashes": [ - "sha256:7ba11294a83468a27be6f06066df8a0f17d954ad05945f28d228aa3f4cd1b03c", - "sha256:f137d03fd5155b1364bea557a7c98168665c239f6c8cedd8f80e81cdfac01567" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==8.5.2" - }, - "cherrypy": { - "hashes": [ - "sha256:56608edd831ad00991ae585625e0206ed61cf1a0850e4b2cc48489fb2308c499", - "sha256:c0a7283f02a384c112a0a18404fd3abd849fc7fd4bec19378067150a2573d2e4" - ], - "index": "pypi", - "version": "==18.6.0" - }, - "docker": { - "hashes": [ - "sha256:0604a74719d5d2de438753934b755bfcda6f62f49b8e4b30969a4b0a2a8a1220", - "sha256:e455fa49aabd4f22da9f4e1c1f9d16308286adc60abaf64bf3e1feafaed81d06" - ], - "index": "pypi", - "version": "==4.4.1" - }, - "gitdb": { - "hashes": [ - "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", - "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" - ], - "markers": "python_version >= '3.4'", - "version": "==4.0.5" - }, - "gitpython": { - "hashes": [ - "sha256:42dbefd8d9e2576c496ed0059f3103dcef7125b9ce16f9d5f9c834aed44a1dac", - "sha256:867ec3dfb126aac0f8296b19fb63b8c4a399f32b4b6fafe84c4b10af5fa9f7b5" - ], - "index": "pypi", - "version": "==3.1.12" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" - }, - "jaraco.classes": { - "hashes": [ - "sha256:2229da0dc3e9f29dd14d9ba7cd86e3f66196acfc91131ede786159fbd794ebd9", - "sha256:24ec75e16d91bbae0fe89312c5cbbe4b2407d40629dc80463653d23868965c5c" - ], - "markers": "python_version >= '3.6'", - "version": "==3.2.0" - }, - "jaraco.collections": { - "hashes": [ - "sha256:bbf6d1c032fe1af08ce1e4654d9354738da79ff51033cf3c215da80f3a9f9419", - "sha256:c82d41122d9c9b5f44ca244188e7f78fc048365ebd92c6cda053fa6c1d185977" - ], - "markers": "python_version >= '3.6'", - "version": "==3.1.0" - }, - "jaraco.functools": { - "hashes": [ - "sha256:7de095757115ebd370ddb14659b48ca83fcd075e78e0b3c575041c0edbf718e0", - "sha256:9e2caddca5620bb682d29b238d46719d062eb2aeafc0cf63043f04c8cd9fd8a7" - ], - "markers": "python_version >= '3.6'", - "version": "==3.1.0" - }, - "jaraco.text": { - "hashes": [ - "sha256:4e3bc45f71435d2828a58473131ae7b43070ab93fc32d8419d6f6d0a61c61c5b", - "sha256:93f261d764cfc2626eb9ca00dbd2dec505631960cba150c927ef1465050f548f" - ], - "markers": "python_version >= '3.6'", - "version": "==3.4.0" - }, - "more-itertools": { - "hashes": [ - "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330", - "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf" - ], - "markers": "python_version >= '3.5'", - "version": "==8.6.0" - }, - "portend": { - "hashes": [ - "sha256:ac0e57ae557f75dc47467579980af152e8f60bc2139547eff8469777d9110379", - "sha256:f101c1aa58ef0718dcf591017adecbdcb54cf528721ecc5a138421511b80a285" - ], - "markers": "python_version >= '3.6'", - "version": "==2.7.0" - }, - "pytz": { - "hashes": [ - "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", - "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" - ], - "version": "==2020.5" - }, - "pyyaml": { - "hashes": [ - "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", - "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", - "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", - "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", - "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", - "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", - "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", - "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", - "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", - "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", - "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", - "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", - "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", - "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", - "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", - "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", - "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", - "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", - "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", - "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc" - ], - "index": "pypi", - "version": "==5.4.1" - }, - "requests": { - "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" - ], - "index": "pypi", - "version": "==2.25.1" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.15.0" - }, - "smmap": { - "hashes": [ - "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714", - "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.5" - }, - "tempora": { - "hashes": [ - "sha256:9af06854fafb26d3d40d3dd6402e8baefaf57f90e48fdc9a94f6b22827a60fb3", - "sha256:e319840007c2913bf2f14ebf1f71b94812335d1ef4ca178e1c65c445a2d63da8" - ], - "markers": "python_version >= '3.6'", - "version": "==4.0.1" - }, - "urllib3": { - "hashes": [ - "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", - "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.3" - }, - "websocket-client": { - "hashes": [ - "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549", - "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010" - ], - "version": "==0.57.0" - }, - "zc.lockfile": { - "hashes": [ - "sha256:307ad78227e48be260e64896ec8886edc7eae22d8ec53e4d528ab5537a83203b", - "sha256:cc33599b549f0c8a248cb72f3bf32d77712de1ff7ee8814312eb6456b42c015f" - ], - "version": "==2.0" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 4ff3146..c10b64a 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,98 @@ -![Katana Logo](/html/images/katana-logo.svg) - # Katana -Katana is the package management tool and interface for SamuraiWTF 5.0+. Specifically Katana is intended to be used by instructors to set up a classroom lab that -will be distributed to their students, or by self-study students to install the tools and targets they desire to use. -_IMPORTANT NOTES:_ -* _Katana runs as root. It is intended only to be used in a temporary classroom environment. Don't install it on any network that is important to you._ -* _Katana is installed as part of the [SamuraiWTF Distribution](https://github.com/SamuraiWTF/samuraiwtf) and is not intended to be installed or run on a -non-SamuraiWTF build._ +**Lab management for web application security training** + +Katana is the lab management solution for [OWASP SamuraiWTF](https://github.com/SamuraiWTF/samuraiwtf). It enables instructors and students to deploy and manage vulnerable web applications for security training environments. + +## Features + +- **Single Executable** - Distributed as a single binary, no runtime dependencies +- **Built-in Reverse Proxy** - Hostname-based routing to targets (e.g., `https://dvwa.samurai.wtf`) +- **Docker-based Targets** - Uses Docker Compose for reliable, isolated deployments +- **Self-signed CA** - HTTPS everywhere with exportable CA certificate for browser trust +- **Web Dashboard** - Modern UI for managing targets at `https://katana.samurai.wtf` +- **Minimal Privileges** - Runs as regular user (only DNS sync requires sudo) + +## Use Cases + +**Local Installation** - Run on a desktop or VM for individual training. Katana manages `/etc/hosts` for local DNS resolution. + +**Remote Installation** - Deploy on a cloud instance (EC2, etc.) for classroom labs. Uses wildcard DNS for access from any machine. + +## Quick Start + +```bash +# 1. Download the latest release +curl -L https://github.com/SamuraiWTF/katana/releases/latest/download/katana-linux-x64 -o katana +chmod +x katana + +# 2. Initialize certificates +./katana cert init + +# 3. Enable privileged port binding +sudo ./katana setup-proxy + +# 4. Sync DNS entries for all targets +sudo ./katana dns sync --all + +# 5. Install a target and start the proxy +./katana install dvwa +./katana proxy start +``` + +Then visit `https://katana.samurai.wtf` in your browser. You'll need to import the CA certificate (run `katana cert export` and import `ca.crt` into your browser). + +## Available Targets + +| Target | Description | +|--------|-------------| +| **dvwa** | Damn Vulnerable Web Application - Classic OWASP Top 10 training | +| **juiceshop** | OWASP Juice Shop - Modern vulnerable web application | +| **dojo-basic-lite** | SamuraiWTF Dojo - SQLi, XSS, and more | +| **dojo-scavenger-lite** | SamuraiWTF Scavenger Hunt challenges | +| **dvga** | Damn Vulnerable GraphQL Application | +| **wrongsecrets** | OWASP WrongSecrets - Secrets management challenges | +| **musashi** | CORS, CSP, and JWT security demonstrations | + +## Requirements + +- **Linux** (Debian/Ubuntu tested; other distributions may work) +- **Docker Engine 20.10+** with Docker Compose V2 +- **OpenSSL** (usually pre-installed) + +See the [Getting Started Guide](docs/getting-started.md) for detailed installation instructions. + +## Documentation + +- [Getting Started](docs/getting-started.md) - Installation and initial setup +- [CLI Reference](docs/cli-reference.md) - Complete command documentation +- [Deployment Guide](docs/deployment-guide.md) - Local vs cloud deployment +- [Troubleshooting](docs/troubleshooting.md) - Common issues and solutions + +### For Developers + +- [Module Development](docs/module-development.md) - Creating new targets and tools +- [Architecture](docs/architecture.md) - System design overview +- [Development Guide](docs/development-guide.md) - Contributing code +## Contributing -## Using Katana -There are two ways to use Katana: +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -### Using Katana with the Web User Interface -By default, Katana should be running at `http://katana.wtf` from within your SamuraiWTF environment. Simply visit the Katana URL from any web browser inside the -environment. From the. GUI you will be able to install, stop, and start any of the Katana-defined tools and targets. Note that an internet connection is required to -install tools and targets. +- **Bug Reports** - [Open an issue](https://github.com/SamuraiWTF/katana/issues/new?template=bug_report.md) +- **Feature Requests** - [Open an issue](https://github.com/SamuraiWTF/katana/issues/new?template=feature_request.md) +- **New Targets** - See the [Module Development Guide](docs/module-development.md) -### Using Katana from the Command Line -From a command line terminal inside your SamuraiWTF environment, simply type katana and the desired arguments. Katana supports the following arguments: +## Security -| Argument | Description | -| :------------- | ------------- | -| `list` | List all available modules that are currently supported by Katana. | -| `install ` | Install the supplied module by name. | -| `remove ` | Remove the supplied module by name. | -| `start ` | Start the supplied module, assuming it is startable. | -| `stop ` | Stop the supplied module, assuming it is stopable. | -| `status ` | Output the status of the supplied module. This will include whether or not it is installed and if it is running (if it is runnable). | -| `lock` | Lock the current set of modules. This will require a restart of Katana. | -| `--update` | This is a special argument for updating katana to the latest version from this repo. For development purposes, an optional second parameter to pull from a specific branch. | +Katana is designed for **training environments only**. Do not use it on production systems or networks you don't control. -## Locking Katana -This feature is for instructors who are setting up a lab environment for students. Once all the desired tools and targets are installed for the lab, running `katana lock` will lock the web UI in place so that -any modules that have not been installed will no longer be listed, and installed modules cannot be removed. +For security concerns, please see [SECURITY.md](SECURITY.md). -To remove the lock, remove the `katana.lock` file from the Katana installation folder (usually `/opt/katana/katana.lock`). +## License -Changing the lock will require a restart of Katana. Note that the Katana web UI is itself a katana module, therefore `katana stop katana` followed by `katana start katana` will restart the web UI. +Apache License 2.0 - See [LICENSE](LICENSE) for details. -# Development +## Acknowledgments -Katana is intended to be a framework so that SamuraiWTF can support a wide range of web targets for teaching application security lessons. -(TODO: Write development guide) +Katana is part of the [OWASP SamuraiWTF](https://github.com/SamuraiWTF/samuraiwtf) project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0a3bbfc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Intended Use + +Katana is designed for **isolated training environments only**. It deploys intentionally vulnerable web applications for educational purposes. + +**Do not use Katana:** +- On production systems +- On networks containing sensitive data +- On publicly accessible servers without understanding the risks + +## Reporting Vulnerabilities + +### For Katana Itself + +If you discover a security vulnerability in Katana (the management tool, not the vulnerable targets it deploys), please [open an issue](https://github.com/SamuraiWTF/katana/issues/new) with: + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +Since Katana is designed for isolated training environments (not production or sensitive networks), public disclosure via GitHub issues is acceptable. + +### For Vulnerable Targets + +The targets deployed by Katana (DVWA, Juice Shop, etc.) are **intentionally vulnerable**. These are not security issues to report - they are the intended functionality. + +If you find a vulnerability in a target application that is: +- Unintentional (breaks the target entirely) +- A real security issue in the target's infrastructure + +Please report it to that project directly, not to Katana. + +## Security Considerations for Users + +### Network Isolation + +When deploying Katana, especially remotely: + +- Use a dedicated network/VPC for training labs +- Restrict access to known IP ranges (e.g. AWS Security Group rules) +- Consider VPN access instead of public exposure +- Monitor for unauthorized access + +### Certificate Trust + +Katana uses self-signed certificates. The CA must be explicitly imported into browsers. This is intentional - it prevents: + +- System-wide certificate trust +- Accidental trust by other applications +- Potential for misuse outside the training context + +### Docker Security + +Katana runs Docker containers with default isolation. For additional security: + +- Keep Docker updated +- Use Docker's user namespace remapping +- Monitor container resource usage +- Regularly prune unused images and containers + +### Credentials + +Default credentials for vulnerable targets are intentional. For classroom use: + +- Brief students on the training nature +- Reset targets between sessions if needed +- Don't use real personal data in training \ No newline at end of file diff --git a/Vagrantfile b/Vagrantfile index af99393..ddd3bcb 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,13 +2,82 @@ # vi: set ft=ruby : Vagrant.configure("2") do |config| - config.vagrant.plugins = [] - config.vm.box = "bento/ubuntu-22.04" - config.vm.synced_folder ".", "/opt/katana" - config.vm.provider "virtualbox" do |vb| - config.vagrant.plugins.append("vagrant-vbguest") - vb.gui = false - vb.memory = "1024" + # Ubuntu Desktop box + config.vm.box = "gusztavvargadr/ubuntu-desktop" + + # VMware-specific configuration + config.vm.provider "vmware_desktop" do |v| + v.vmx["memsize"] = "4096" + v.vmx["numvcpus"] = "2" + v.gui = true end - config.vm.provision "shell", name: "setup", path: "test/provision-ubuntu.sh", env: { 'DEBIAN_FRONTEND': 'noninteractive'} + + # Port forwarding for Katana proxy + config.vm.network "forwarded_port", guest: 443, host: 8443, host_ip: "127.0.0.1" + + # Sync the project directory with exclusions + config.vm.synced_folder ".", "/home/vagrant/katana", type: "rsync", + rsync__exclude: [ + ".git/", + "bin/", + "node_modules/", + ".vagrant/", + ".DS_Store", + "src/ui/embedded-assets.ts" + ], + rsync__auto: true + + # Provisioning script + config.vm.provision "shell", inline: <<-SHELL + set -e + + echo "==> Installing system dependencies..." + apt-get update + apt-get install -y curl unzip + + echo "==> Installing Docker..." + # Install Docker if not already installed + if ! command -v docker &> /dev/null; then + curl -fsSL https://get.docker.com -o get-docker.sh + sh get-docker.sh + rm get-docker.sh + usermod -aG docker vagrant + fi + + echo "==> Installing Bun..." + # Install Bun as vagrant user + su - vagrant -c 'curl -fsSL https://bun.sh/install | bash' + + echo "==> Creating Docker network..." + docker network create katana-net 2>/dev/null || true + + echo "==> Setting up Katana directories..." + su - vagrant -c 'mkdir -p ~/.config/katana ~/.local/share/katana' + + echo "==> Creating katana symlink in PATH..." + # Create symlink so 'katana' command works from anywhere + # Note: The target doesn't need to exist yet - symlink will work once binary is built + mkdir -p /home/vagrant/katana/bin + ln -sf /home/vagrant/katana/bin/katana /usr/local/bin/katana + chown vagrant:vagrant /home/vagrant/katana/bin + + echo "" + echo "==> VM provisioning complete!" + echo "" + echo "Next steps:" + echo " 1. vagrant ssh" + echo " 2. cd katana" + echo " 3. bun install" + echo " 4. bun run build:ui && bun run build" + echo " 5. sudo katana setup-proxy" + echo " 6. katana cert init" + echo " 7. sudo katana dns sync --all" + echo " 8. katana doctor" + echo "" + echo "Development workflow:" + echo " - Edit files on Windows (your IDE)" + echo " - Run 'vagrant rsync-auto' in separate terminal to auto-sync changes" + echo " - Build/test in VM via 'vagrant ssh'" + echo "" + SHELL end diff --git a/Vagrantfile.centos8 b/Vagrantfile.centos8 deleted file mode 100644 index 27d7584..0000000 --- a/Vagrantfile.centos8 +++ /dev/null @@ -1,14 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -Vagrant.configure("2") do |config| - config.vagrant.plugins = [] - config.vm.box = "generic/centos8" - config.vm.synced_folder ".", "/opt/katana" - config.vm.provider "virtualbox" do |vb| - config.vagrant.plugins.append("vagrant-vbguest") - vb.gui = false - vb.memory = "1024" - end - config.vm.provision "shell", name: "setup", path: "test/provision-centos.sh" -end diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f6f9675 --- /dev/null +++ b/biome.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "noSvgWithoutTitle": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "files": { + "ignore": [ + "node_modules", + "dist", + "bin", + "*.yml", + "*.yaml", + "*.md", + "src/ui/embedded-assets.ts" + ] + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..715a685 --- /dev/null +++ b/bun.lock @@ -0,0 +1,348 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "katana2", + "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "commander": "^11.1.0", + "dockerode": "^4.0.0", + "lucide-react": "^0.468.0", + "next-themes": "^0.4.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", + "yaml": "^2.3.4", + "zod": "^3.22.4", + }, + "devDependencies": { + "@biomejs/biome": "^1.4.1", + "@types/bun": "latest", + "@types/dockerode": "^3.3.23", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + }, + }, + }, + "packages": { + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], + + "@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + + "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "docker-modem": ["docker-modem@5.0.6", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ=="], + + "dockerode": ["dockerode@4.0.9", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.6", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nan": ["nan@2.24.0", "", {}, "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "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" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..df12083 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/ui/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/ui/components", + "utils": "@/ui/lib/utils", + "ui": "@/ui/components/ui", + "lib": "@/ui/lib", + "hooks": "@/ui/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..aeb5d02 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,290 @@ +# Architecture Overview + +This document describes Katana's system design for contributors and developers. + +## High-Level Architecture + +Katana is a single-process application that serves as both a CLI tool and a reverse proxy server: + +``` + ┌─────────────────────────────────────┐ + │ Katana Process │ + │ │ +User Request │ ┌─────────┐ ┌───────────────┐ │ +https://dvwa.samurai.wtf ───────▶│ Proxy │───▶│ Docker Network│ │ + │ │ Router │ │ (katana-net) │ │ + │ └─────────┘ └───────┬───────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌─────────┐ ┌─────────────┐ │ +https://katana.samurai.wtf ─────▶│Dashboard│ │ Containers │ │ + │ │ UI │ │ DVWA, Juice │ │ + │ └─────────┘ │ Shop, etc │ │ + │ └─────────────┘ │ + └─────────────────────────────────────┘ +``` + +## Core Components + +### CLI Entry Point (`src/cli.ts`) + +The command-line interface built with [Commander.js](https://github.com/tj/commander.js): + +- Parses command-line arguments +- Routes to appropriate command handlers +- Provides help and version information + +### Server (`src/server.ts`) + +The HTTPS reverse proxy and web dashboard server: + +- Binds to ports 443 (HTTPS) and 80 (HTTP redirect) +- Routes requests based on hostname +- Serves the web dashboard UI +- Provides REST API endpoints + +### Core Managers (`src/core/`) + +| Manager | File | Responsibility | +|---------|------|----------------| +| **ConfigManager** | `config-manager.ts` | Load/save `config.yml`, provide defaults | +| **StateManager** | `state-manager.ts` | Load/save `state.yml`, atomic writes, lock state | +| **ModuleLoader** | `module-loader.ts` | Scan and validate module definitions | +| **ComposeManager** | `compose-manager.ts` | Docker Compose operations (up, down, status) | +| **CertManager** | `cert-manager.ts` | Certificate generation and management | +| **ProxyRouter** | `proxy-router.ts` | Hostname-to-container routing | +| **DockerClient** | `docker-client.ts` | Docker API wrapper | + +### Platform Layer (`src/platform/`) + +Abstractions for platform-specific operations: + +``` +src/platform/ +├── index.ts # Platform detection +├── types.ts # Interface definitions +└── linux/ + └── dns-manager.ts # /etc/hosts management +``` + +Currently Linux-only; structure allows future Windows support. + +### Types (`src/types/`) + +TypeScript types and Zod schemas: + +| File | Contents | +|------|----------| +| `config.ts` | Configuration types and schema | +| `state.ts` | State file types and schema | +| `module.ts` | Module definition types and schema | +| `docker.ts` | Docker-related types | +| `errors.ts` | Custom error classes | + +### UI (`src/ui/`) + +React-based web dashboard: + +- Built with React + Tailwind CSS + shadcn/ui +- Bundled into the executable +- Served from memory (no external files needed) + +## Data Flow + +### Request Routing + +``` +1. Request arrives at port 443 + └─▶ https://dvwa.samurai.wtf/login + +2. Server extracts hostname + └─▶ "dvwa.samurai.wtf" + +3. ProxyRouter looks up route + └─▶ { containerName: "katana-dvwa-dvwa-1", port: 80 } + +4. Resolve container IP on katana-net + └─▶ "172.18.0.3" + +5. Forward request to container + └─▶ http://172.18.0.3:80/login + +6. Return response to client +``` + +### Target Lifecycle + +``` +Install: +┌─────────────────────────────────────────────────────────┐ +│ 1. Load module definition (module.yml) │ +│ 2. Ensure katana-net network exists │ +│ 3. Run docker compose up -d │ +│ 4. Register proxy routes in state │ +│ 5. Save state │ +│ 6. Remind user to sync DNS │ +└─────────────────────────────────────────────────────────┘ + +Start/Stop: +┌─────────────────────────────────────────────────────────┐ +│ docker compose start/stop │ +└─────────────────────────────────────────────────────────┘ + +Remove: +┌─────────────────────────────────────────────────────────┐ +│ 1. Run docker compose down │ +│ 2. Remove routes from state │ +│ 3. Save state │ +└─────────────────────────────────────────────────────────┘ +``` + +## File Locations + +### User Configuration + +| File | Location | Purpose | +|------|----------|---------| +| Config | `~/.config/katana/config.yml` | User configuration | +| State | `~/.local/share/katana/state.yml` | Installation state | +| Certs | `~/.local/share/katana/certs/` | CA and server certificates | + +### Project Structure + +``` +katana/ +├── src/ +│ ├── cli.ts # CLI entry point +│ ├── server.ts # Web server + proxy +│ ├── commands/ # CLI command implementations +│ ├── core/ # Business logic managers +│ ├── platform/ # Platform-specific code +│ ├── types/ # TypeScript types + schemas +│ ├── utils/ # Utility functions +│ ├── ui/ # React dashboard +│ └── server/ # API route handlers +├── modules/ +│ ├── targets/ # Target module definitions +│ └── tools/ # Tool module definitions +├── tests/ +│ └── e2e/ # End-to-end test scripts +└── docs/ # Documentation +``` + +## Key Design Decisions + +### Single Process Model + +The CLI and proxy server run in the same process. This simplifies: +- State management (no IPC needed) +- Deployment (single binary) +- Route updates (immediate, no restart needed) + +### Docker Compose Over Direct Docker API + +We use `docker compose` CLI commands rather than direct API calls because: +- Compose handles multi-container orchestration +- Health checks and dependencies work out of the box +- Familiar format for contributors + +### Shared Docker Network + +All targets join the `katana-net` external network: +- Proxy can reach any container by name +- Containers can communicate if needed +- No port allocation conflicts + +### State File as Cache + +The state file (`state.yml`) caches installation metadata: +- Docker is source of truth for container status +- State tracks what Katana installed (vs. unrelated containers) +- Enables quick status checks without querying Docker + +### Self-Signed CA + +Certificates are self-signed rather than using Let's Encrypt: +- Works for local development (no public domain needed) +- Consistent across local and remote deployments +- Users explicitly trust the CA (security-conscious approach) + +## API Routes + +The server exposes REST API endpoints for the dashboard: + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/modules` | List all modules | +| GET | `/api/modules/:name` | Get module details | +| POST | `/api/modules/:name/install` | Install module | +| POST | `/api/modules/:name/remove` | Remove module | +| POST | `/api/modules/:name/start` | Start module | +| POST | `/api/modules/:name/stop` | Stop module | +| GET | `/api/system` | System status | +| GET | `/api/certs/ca` | Download CA certificate | +| GET | `/api/operations/:id/stream` | SSE stream for operation progress | + +## Testing + +### Automated Tests + +End-to-end tests in `tests/e2e/`: + +| Script | Tests | +|--------|-------| +| `build.sh` | TypeScript compilation, linting, binary build | +| `cli.sh` | CLI commands work correctly | +| `state.sh` | State file management | +| `lifecycle.sh` | Target install/start/stop/remove | +| `api.sh` | REST API endpoints | +| `proxy.sh` | Reverse proxy routing | + +Run all tests: +```bash +./tests/e2e/run-all.sh +``` + +### Manual Testing + +The dashboard UI and browser certificate import require manual testing. See [Development Guide](development-guide.md). + +## Dependencies + +### Runtime + +| Package | Purpose | +|---------|---------| +| commander | CLI framework | +| zod | Schema validation | +| yaml | YAML parsing | + +### Build-time + +| Package | Purpose | +|---------|---------| +| bun | Runtime, bundler, test runner | +| biome | Linter and formatter | +| typescript | Type checking | +| react, tailwindcss | Dashboard UI | + +### System + +| Dependency | Purpose | +|------------|---------| +| Docker | Container runtime | +| OpenSSL | Certificate generation | + +## Future Considerations + +### Windows Support + +The platform abstraction layer (`src/platform/`) allows adding Windows support: +- DNS: `C:\Windows\System32\drivers\etc\hosts` +- Docker: Docker Desktop works on Windows +- Ports: May require running as Administrator + +### Additional Module Types + +The architecture could support: +- Kubernetes-based targets +- Remote module repositories +- Module versioning diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..b9a039d --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,448 @@ +# CLI Reference + +Complete reference for all Katana commands. + +## Global Options + +These options work with any command: + +| Option | Description | +|--------|-------------| +| `-c, --config ` | Path to custom configuration file | +| `-h, --help` | Display help for command | +| `-V, --version` | Display version number | + +## Target Management + +### `katana install ` + +Install a target or tool module. + +```bash +katana install dvwa +katana install juiceshop +``` + +**Options:** +- `--skip-dns` - Skip the DNS update reminder + +**Notes:** +- Creates and starts Docker containers for the target +- Registers proxy routes for hostname-based access +- After installing, run `sudo katana dns sync` to update `/etc/hosts` + +--- + +### `katana remove ` + +Remove an installed target or tool. + +```bash +katana remove dvwa +``` + +**Notes:** +- Stops and removes Docker containers +- Removes proxy route registrations +- Does not automatically update DNS (run `sudo katana dns sync` afterward) + +--- + +### `katana start ` + +Start a stopped target. + +```bash +katana start dvwa +``` + +**Notes:** +- Only works on installed targets that are currently stopped +- Use `katana status` to see current state + +--- + +### `katana stop ` + +Stop a running target. + +```bash +katana stop dvwa +``` + +**Notes:** +- Containers remain installed, just stopped +- Use `katana start` to restart + +--- + +### `katana status` + +Show system status overview. + +```bash +katana status +``` + +**Output includes:** +- Lock state +- Installation type and domain +- Number of installed/running targets +- List of targets with their status (running/stopped) +- Configuration file locations + +--- + +### `katana logs ` + +View logs from a target's containers. + +```bash +# Show last 100 lines +katana logs dvwa + +# Follow log output (like tail -f) +katana logs -f dvwa + +# Show last 50 lines +katana logs -t 50 dvwa +``` + +**Options:** +- `-f, --follow` - Follow log output in real-time +- `-t, --tail ` - Number of lines to show (default: 100) + +--- + +### `katana list [category]` + +List available modules. + +```bash +# List all modules +katana list + +# List only targets +katana list targets + +# List only tools +katana list tools + +# Show only installed modules +katana list --installed +``` + +**Options:** +- `--installed` - Show only installed modules + +**Output shows:** +- Module name +- Description +- Installation status (`[installed]` marker) + +--- + +## System Management + +### `katana lock` + +Lock the system to prevent modifications. + +```bash +katana lock +``` + +**Notes:** +- Prevents `install` and `remove` operations +- Useful for classroom environments where instructors set up labs +- Use `katana unlock` to re-enable modifications + +--- + +### `katana unlock` + +Unlock the system to allow modifications. + +```bash +katana unlock +``` + +--- + +### `katana doctor` + +Run health checks on the system. + +```bash +katana doctor + +# Output as JSON (for scripting) +katana doctor --json +``` + +**Options:** +- `--json` - Output results as JSON + +**Checks performed:** +1. Docker daemon running +2. User has Docker permissions +3. Docker network exists +4. OpenSSL available +5. Certificates initialized +6. Certificates valid (with expiration warning) +7. Port 443 capability +8. DNS entries in sync +9. State file valid + +**Exit codes:** +- `0` - All checks passed +- `1` - One or more checks failed + +--- + +### `katana cleanup` + +Remove orphaned resources and fix inconsistencies. + +```bash +# Show what would be cleaned up +katana cleanup --dry-run + +# Run cleanup +katana cleanup + +# Also prune unused Docker images +katana cleanup --prune +``` + +**Options:** +- `--dry-run` - Show what would be done without making changes +- `--prune` - Also prune unused Docker images + +**Actions performed:** +- Remove orphaned containers (from deleted targets) +- Report DNS sync status +- Optionally prune unused Docker images + +--- + +## Proxy Management + +### `katana proxy start` + +Start the reverse proxy server. + +```bash +katana proxy start +``` + +**Notes:** +- Runs in foreground (use Ctrl+C to stop) +- Listens on ports 443 (HTTPS) and 80 (HTTP redirect) +- Serves the web dashboard at `https://katana.` +- Proxies requests to target containers based on hostname + +For background operation, use a process manager like systemd. See [Deployment Guide](deployment-guide.md). + +--- + +### `katana proxy status` + +Show proxy configuration and registered routes. + +```bash +katana proxy status +``` + +**Output includes:** +- HTTPS and HTTP ports +- Dashboard URL +- Docker network name +- List of configured routes (hostname → container mapping) + +--- + +## DNS Management + +### `katana dns sync` + +Synchronize `/etc/hosts` with target hostnames. + +```bash +# Sync hostnames for installed targets only +sudo katana dns sync + +# Sync hostnames for ALL available targets +sudo katana dns sync --all +``` + +**Options:** +- `--all` - Sync all available targets, not just installed ones + +**Requires:** sudo (writes to `/etc/hosts`) + +**Notes:** +- Adds entries with `# katana-managed` marker +- Preserves non-Katana entries +- Idempotent (safe to run multiple times) +- For remote installations, use wildcard DNS instead + +--- + +### `katana dns list` + +List DNS entries from `/etc/hosts`. + +```bash +# Show Katana-managed entries only +katana dns list + +# Show all entries +katana dns list --all +``` + +**Options:** +- `--all` - Show all entries, not just Katana-managed + +--- + +## Certificate Management + +### `katana cert init` + +Initialize the Certificate Authority and generate server certificates. + +```bash +katana cert init +``` + +**Notes:** +- Creates a self-signed CA (valid 10 years) +- Generates wildcard server certificate (valid 1 year) +- Certificates stored in `~/.local/share/katana/certs/` +- If CA already exists, keeps CA and regenerates server certificate + +--- + +### `katana cert renew` + +Renew the server certificate (keeps existing CA). + +```bash +katana cert renew +``` + +**Notes:** +- Keeps the existing CA (no need to re-import in browsers) +- Generates new server certificate (valid 1 year) +- Use when certificate is expiring + +--- + +### `katana cert export [path]` + +Export the CA certificate for browser import. + +```bash +# Export to current directory +katana cert export + +# Export to specific path +katana cert export /tmp/katana-ca.crt +``` + +**Arguments:** +- `[path]` - Destination path (default: `./ca.crt`) + +**Notes:** +- Creates a copy of the CA certificate +- Import this file into your browser to trust Katana's HTTPS certificates + +--- + +### `katana cert status` + +Show certificate status and expiration. + +```bash +katana cert status +``` + +**Output includes:** +- CA initialization status +- Server certificate validity +- Days until expiration +- Certificate file location + +--- + +## Setup Commands + +### `katana setup-proxy` + +Configure the system for proxy operation. + +```bash +sudo katana setup-proxy +``` + +**Requires:** sudo + +**Actions performed:** +- Sets `cap_net_bind_service` capability on the binary +- This allows binding to ports 443 and 80 without running as root + +**Notes:** +- Only needs to be run once after installation +- Must be re-run if the binary is replaced (e.g., after updates) + +--- + +## Common Workflows + +### First-time Setup + +```bash +katana cert init # Generate certificates +sudo katana setup-proxy # Enable port 443 binding +sudo katana dns sync --all # Add all hostnames to /etc/hosts +``` + +### Install and Use a Target + +```bash +katana install dvwa # Install the target +katana proxy start # Start the proxy (foreground) +# Visit https://dvwa.samurai.wtf in browser +``` + +### Classroom Setup (Instructor) + +```bash +# Install desired targets +katana install dvwa +katana install juiceshop +katana install webgoat + +# Lock system to prevent student modifications +katana lock + +# Start proxy +katana proxy start +``` + +### Check System Health + +```bash +katana doctor # Run all health checks +katana status # Quick status overview +katana proxy status # Show proxy routes +``` + +### Maintenance + +```bash +katana cleanup --dry-run # See what would be cleaned +katana cleanup --prune # Clean up and prune images +katana cert renew # Renew expiring certificate +``` diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..30621ca --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,352 @@ +# Deployment Guide + +Katana supports two deployment modes: **local** (desktop/VM) and **remote** (cloud server). This guide covers both scenarios. + +## Local Deployment + +Use local deployment when running Katana on a desktop machine or VM for individual training. + +### Overview + +- DNS resolution via `/etc/hosts` +- Access targets at `https://.samurai.wtf` (e.g., `https://dvwa.samurai.wtf`) +- Dashboard at `https://katana.samurai.wtf` +- Single user, local access only + +### Setup Steps + +1. **Install Katana** (see [Getting Started](getting-started.md)) + +2. **Initialize certificates:** + ```bash + katana cert init + ``` + +3. **Enable privileged ports:** + ```bash + sudo katana setup-proxy + ``` + +4. **Sync DNS for all targets:** + ```bash + sudo katana dns sync --all + ``` + +5. **Import CA certificate** into your browser (see [Getting Started](getting-started.md#5-import-ca-certificate-in-browser)) + +6. **Install targets and start proxy:** + ```bash + katana install dvwa + katana proxy start + ``` + +### Running as a Service (Optional) + +For convenience, you can run the proxy as a systemd service: + +```ini +# /etc/systemd/system/katana.service +[Unit] +Description=Katana Proxy Server +After=docker.service +Requires=docker.service + +[Service] +Type=simple +User=YOUR_USERNAME +ExecStart=/usr/local/bin/katana proxy start +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable katana +sudo systemctl start katana +``` + +--- + +## Remote Deployment + +Use remote deployment for classroom labs where students access targets from their own machines. + +### Overview + +- DNS resolution via wildcard DNS record +- Access targets at `https://.` (e.g., `https://dvwa.lab01.training.example.com`) +- Dashboard at `https://katana.` +- Multiple users can access from any network location + +### Prerequisites + +- A Linux server (EC2, DigitalOcean, etc.) +- A domain name you control +- Ability to create DNS records + +### Infrastructure Setup + +#### 1. Provision a Server + +**AWS EC2 example:** +- AMI: Ubuntu 22.04 LTS +- Instance type: t3.medium or larger (depends on number of targets) +- Storage: 30GB+ (Docker images need space) + +**Security Group rules:** +| Type | Port | Source | +|------|------|--------| +| SSH | 22 | Your IP | +| HTTP | 80 | 0.0.0.0/0 | +| HTTPS | 443 | 0.0.0.0/0 | + +#### 2. Configure Wildcard DNS + +Create a wildcard DNS record pointing to your server's public IP. + +**AWS Route53 example:** + +| Name | Type | Value | +|------|------|-------| +| `*.lab01.training.example.com` | A | `203.0.113.42` | + +This single record handles all subdomains: +- `katana.lab01.training.example.com` +- `dvwa.lab01.training.example.com` +- `juiceshop.lab01.training.example.com` +- etc. + +#### 3. Install Docker + +```bash +# Install Docker +curl -fsSL https://get.docker.com | sh + +# Add your user to docker group +sudo usermod -aG docker $USER +newgrp docker + +# Verify +docker --version +docker compose version +``` + +### Katana Setup + +#### 1. Install Katana + +```bash +curl -L https://github.com/SamuraiWTF/katana/releases/latest/download/katana-linux-x64 -o katana +chmod +x katana +sudo mv katana /usr/local/bin/ +``` + +#### 2. Configure for Remote Mode + +Edit the configuration file: + +```bash +mkdir -p ~/.config/katana +nano ~/.config/katana/config.yml +``` + +Set remote configuration: + +```yaml +install_type: remote +base_domain: lab01.training.example.com +dashboard_hostname: katana + +paths: + modules: /opt/katana/modules + data: /var/lib/katana + certs: /var/lib/katana/certs + state: /var/lib/katana/state.yml + +proxy: + http_port: 80 + https_port: 443 + # bind_address: "0.0.0.0" # Optional: Override bind address + # Defaults: local → 127.0.0.1, remote → 0.0.0.0 + +docker_network: katana-net +``` + +Create the data directories: + +```bash +sudo mkdir -p /opt/katana /var/lib/katana +sudo chown $USER:$USER /opt/katana /var/lib/katana +``` + +#### 3. Initialize Certificates + +Generate certificates for your domain: + +```bash +katana cert init +``` + +The wildcard certificate will cover `*.lab01.training.example.com`. + +#### 4. Enable Privileged Ports + +```bash +sudo katana setup-proxy +``` + +#### 5. Install Targets + +```bash +katana install dvwa +katana install juiceshop +# ... install other targets as needed +``` + +#### 6. Create systemd Service + +```bash +sudo nano /etc/systemd/system/katana.service +``` + +```ini +[Unit] +Description=Katana Proxy Server +After=docker.service +Requires=docker.service + +[Service] +Type=simple +User=YOUR_USERNAME +Environment="HOME=/home/YOUR_USERNAME" +ExecStart=/usr/local/bin/katana proxy start +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable katana +sudo systemctl start katana +sudo systemctl status katana +``` + +### Student Access + +Students can now access: + +- **Dashboard:** `https://katana.lab01.training.example.com` +- **DVWA:** `https://dvwa.lab01.training.example.com` +- **Juice Shop:** `https://juiceshop.lab01.training.example.com` + +**Important:** Students must import the CA certificate into their browsers. Provide them with: + +1. Download link: `https://katana.lab01.training.example.com/api/certs/ca` (from the dashboard) +2. Instructions for importing into their browser + +### Classroom Setup Tips + +**Lock the system** after setting up targets: + +```bash +katana lock +``` + +This prevents accidental modifications during class. + +**Monitor status:** + +```bash +katana status +katana doctor +sudo systemctl status katana +``` + +**View logs:** + +```bash +# Katana proxy logs +sudo journalctl -u katana -f + +# Target container logs +katana logs dvwa -f +``` + +--- + +## Security Considerations + +### Training Environments Only + +Katana is designed for **isolated training environments**. The targets it deploys are intentionally vulnerable applications. + +**Do not:** +- Deploy on production networks +- Expose to the public internet without understanding the risks +- Use real credentials or sensitive data in training labs + +### Network Isolation + +For remote deployments, consider: + +- Using a dedicated VPC/subnet for training labs +- Restricting access to known student IP ranges +- Using VPN for access instead of public exposure + +### Certificate Trust + +The self-signed CA is only trusted by browsers that import it. This is intentional - it prevents the certificates from being trusted system-wide or by other applications. + +--- + +## Troubleshooting Deployment + +### DNS Not Resolving (Remote) + +Verify your wildcard DNS: + +```bash +dig +short anything.lab01.training.example.com +# Should return your server IP +``` + +### Port Already in Use + +Check what's using ports 80/443: + +```bash +sudo lsof -i :443 +sudo lsof -i :80 +``` + +### Proxy Won't Start + +Check the service status and logs: + +```bash +sudo systemctl status katana +sudo journalctl -u katana -n 50 +``` + +### Docker Permission Denied + +Ensure user is in docker group: + +```bash +groups # Should include 'docker' +# If not: +sudo usermod -aG docker $USER +newgrp docker +``` + +See [Troubleshooting](troubleshooting.md) for more common issues. diff --git a/docs/development-guide.md b/docs/development-guide.md new file mode 100644 index 0000000..f8641ce --- /dev/null +++ b/docs/development-guide.md @@ -0,0 +1,338 @@ +# Development Guide + +This guide covers setting up a development environment and contributing code to Katana. + +## Prerequisites + +### Required + +- **Bun** (latest) - JavaScript runtime and toolkit + ```bash + curl -fsSL https://bun.sh/install | bash + ``` + +- **Docker Engine 20.10+** with Docker Compose V2 + ```bash + docker --version + docker compose version + ``` + +- **Git** + +### Recommended + +- **VS Code** with extensions: + - Biome (linting/formatting) + - TypeScript + - Docker + +## Getting Started + +### 1. Clone the Repository + +```bash +git clone https://github.com/SamuraiWTF/katana.git +cd katana +``` + +### 2. Install Dependencies + +```bash +bun install +``` + +### 3. Run from Source + +```bash +# Run CLI commands directly +bun run src/cli.ts --help +bun run src/cli.ts status +bun run src/cli.ts list + +# Run with hot reload (for server development) +bun --hot src/cli.ts proxy start +``` + +### 4. Build the Binary + +```bash +bun build --compile src/cli.ts --outfile bin/katana +``` + +The compiled binary is at `bin/katana`. + +## Project Structure + +``` +katana/ +├── src/ +│ ├── cli.ts # CLI entry point +│ ├── server.ts # Web server + reverse proxy +│ ├── commands/ # CLI command implementations +│ │ ├── install.ts +│ │ ├── remove.ts +│ │ └── ... +│ ├── core/ # Business logic +│ │ ├── config-manager.ts +│ │ ├── state-manager.ts +│ │ ├── module-loader.ts +│ │ ├── compose-manager.ts +│ │ ├── cert-manager.ts +│ │ ├── proxy-router.ts +│ │ └── docker-client.ts +│ ├── platform/ # Platform-specific code +│ │ └── linux/ +│ ├── types/ # TypeScript types + Zod schemas +│ ├── utils/ # Utility functions +│ ├── ui/ # React dashboard +│ │ ├── App.tsx +│ │ ├── components/ +│ │ └── hooks/ +│ └── server/ # API route handlers +│ └── routes/ +├── modules/ # Module definitions +│ ├── targets/ +│ └── tools/ +├── tests/ +│ └── e2e/ # End-to-end tests +├── docs/ # Documentation +├── package.json +├── tsconfig.json +└── biome.json +``` + +## Development Workflow + +### Making Changes + +1. Create a feature branch: + ```bash + git checkout -b feature/my-feature + ``` + +2. Make your changes + +3. Run type checking: + ```bash + bunx tsc --noEmit + ``` + +4. Run linting: + ```bash + bunx biome check src/ + ``` + +5. Fix formatting: + ```bash + bunx biome format --write src/ + ``` + +6. Run tests: + ```bash + ./tests/e2e/run-all.sh + ``` + +### Code Style + +We use [Biome](https://biomejs.dev/) for linting and formatting: + +```bash +# Check for issues +bunx biome check src/ + +# Fix auto-fixable issues +bunx biome check --apply src/ + +# Format code +bunx biome format --write src/ +``` + +Configuration is in `biome.json`. + +### TypeScript + +- Strict mode is enabled +- Use Zod for runtime validation +- Prefer explicit types for public APIs + +```typescript +// Good: explicit return type +async function loadModule(name: string): Promise { + // ... +} + +// Good: use Zod for validation +const result = ModuleSchema.parse(data); +``` + +## Testing + +### Automated E2E Tests + +The test suite is in `tests/e2e/`: + +```bash +# Run all tests +./tests/e2e/run-all.sh + +# Run individual test +./tests/e2e/build.sh # Build verification +./tests/e2e/cli.sh # CLI commands +./tests/e2e/state.sh # State management +./tests/e2e/lifecycle.sh # Target lifecycle +./tests/e2e/api.sh # API endpoints (requires proxy) +./tests/e2e/proxy.sh # Proxy routing (requires proxy) +``` + +### Manual Testing Checklist + +Some features require manual testing: + +- [ ] Dashboard loads at `https://katana.samurai.wtf` +- [ ] Install target via dashboard +- [ ] Start/stop target via dashboard +- [ ] Remove target via dashboard +- [ ] Theme toggle (dark/light) +- [ ] CA certificate download +- [ ] Certificate import in browser (Firefox, Chrome) +- [ ] Target accessible after cert import + +### Writing Tests + +For new features, add test cases to the appropriate E2E script or create a new script following the existing pattern. + +## Building + +### Development Build + +```bash +bun build --compile src/cli.ts --outfile bin/katana +``` + +### UI Build + +The UI is built separately and embedded: + +```bash +# Build UI assets +bun run src/ui/build.ts + +# Then build the binary +bun build --compile src/cli.ts --outfile bin/katana +``` + +### After Building + +After building a new binary, you need to re-apply setcap for privileged port binding: + +```bash +sudo setcap cap_net_bind_service=+ep ./bin/katana +``` + +## Common Development Tasks + +### Adding a New CLI Command + +1. Create command file in `src/commands/`: + ```typescript + // src/commands/mycommand.ts + export async function myCommand(args: MyArgs): Promise { + // Implementation + } + ``` + +2. Register in `src/cli.ts`: + ```typescript + program + .command("mycommand") + .description("Description") + .action(async () => { + await myCommand(); + }); + ``` + +### Adding a New API Endpoint + +1. Add route handler in `src/server/routes/` + +2. Register in `src/server.ts` route handling + +### Adding a New Target Module + +See [Module Development Guide](module-development.md). + +### Modifying the Dashboard + +1. Edit components in `src/ui/components/` +2. Test with hot reload: `bun --hot src/cli.ts proxy start` +3. Rebuild: `bun run src/ui/build.ts` + +## Debugging + +### CLI Debugging + +```bash +# Run with debug output +DEBUG=* bun run src/cli.ts status +``` + +### Container Debugging + +```bash +# Check container status +docker ps -a | grep katana + +# View container logs +docker logs katana-dvwa-dvwa-1 + +# Inspect container +docker inspect katana-dvwa-dvwa-1 + +# Shell into container +docker exec -it katana-dvwa-dvwa-1 /bin/sh +``` + +### Proxy Debugging + +```bash +# Test proxy routing manually +curl -k -H "Host: dvwa.samurai.wtf" https://localhost/ + +# Check what's listening on port 443 +sudo lsof -i :443 +``` + +## Pull Request Guidelines + +### Before Submitting + +1. **Type check passes:** `bunx tsc --noEmit` +2. **Lint passes:** `bunx biome check src/` +3. **Tests pass:** `./tests/e2e/run-all.sh` +4. **Manual testing** done for UI changes + +### PR Description + +Include: +- What the change does +- Why it's needed +- How to test it +- Screenshots for UI changes + +### Commit Messages + +Use clear, descriptive commit messages: + +``` +Add certificate renewal reminder to doctor command + +- Check certificate expiration in doctor command +- Warn if certificate expires within 30 days +- Add help message with renewal instructions +``` + +## Getting Help + +- **Architecture questions:** See [Architecture](architecture.md) +- **Module development:** See [Module Development](module-development.md) +- **Issues:** Open a [GitHub issue](https://github.com/SamuraiWTF/katana/issues) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..5b8bc13 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,215 @@ +# Getting Started + +This guide covers system requirements, installation, and initial setup for Katana. + +## System Requirements + +### Operating System + +- **Linux** (required) + - Debian/Ubuntu - Tested and recommended + - Other distributions may work but are not officially tested + +Windows and macOS are not currently supported. + +### Docker + +- **Docker Engine 20.10+** or Docker Desktop +- **Docker Compose V2** (included with modern Docker installations) + +To verify your Docker installation: + +```bash +docker --version # Should be 20.10 or higher +docker compose version # Should show "Docker Compose version v2.x.x" +``` + +If Docker Compose V2 is not available, install it: + +```bash +# Debian/Ubuntu +sudo apt update +sudo apt install docker-compose-plugin +``` + +### OpenSSL + +OpenSSL is required for certificate generation. It's pre-installed on most Linux systems. + +```bash +openssl version # Should show version info +``` + +If not installed: + +```bash +# Debian/Ubuntu +sudo apt install openssl +``` + +## Installation + +### Option 1: Download Pre-built Binary (Recommended) + +Download the latest release from GitHub: + +```bash +# Download the binary +curl -L https://github.com/SamuraiWTF/katana/releases/latest/download/katana-linux-x64 -o katana + +# Make it executable +chmod +x katana + +# Move to a location in your PATH (optional) +sudo mv katana /usr/local/bin/ +``` + +### Option 2: Build from Source + +Requires [Bun](https://bun.sh/) runtime. + +```bash +# Install Bun (if not already installed) +curl -fsSL https://bun.sh/install | bash + +# Clone the repository +git clone https://github.com/SamuraiWTF/katana.git +cd katana + +# Install dependencies +bun install + +# Build the executable +bun build --compile src/cli.ts --outfile bin/katana + +# The binary is now at bin/katana +``` + +## Initial Setup + +After installation, complete these one-time setup steps: + +### 1. First Run (Creates Configuration) + +Run any command to initialize the configuration file: + +```bash +katana --version +``` + +This creates `~/.config/katana/config.yml` with default settings. + +### 2. Initialize Certificates + +Generate the self-signed CA and server certificates: + +```bash +katana cert init +``` + +This creates certificates in `~/.local/share/katana/certs/`. + +### 3. Enable Privileged Port Binding + +Katana needs to bind to ports 443 (HTTPS) and 80 (HTTP redirect). Run the setup command: + +```bash +sudo katana setup-proxy +``` + +This uses `setcap` to grant the binary the capability to bind to privileged ports without running as root. + +### 4. Configure DNS + +For **local installations** (desktop/VM), sync the hosts file: + +```bash +# Sync DNS for all available targets +sudo katana dns sync --all +``` + +This adds entries to `/etc/hosts` for all targets (e.g., `127.0.0.1 dvwa.samurai.wtf`). + +For **remote installations** (cloud/server), configure wildcard DNS instead. See the [Deployment Guide](deployment-guide.md). + +### 5. Import CA Certificate in Browser + +Export the CA certificate and import it into your browser: + +```bash +katana cert export +# Creates ca.crt in current directory +``` + +**Firefox:** +1. Settings → Privacy & Security → Certificates → View Certificates +2. Authorities tab → Import +3. Select `ca.crt` and trust for websites + +**Chrome/Chromium:** +1. Settings → Privacy and security → Security → Manage certificates +2. Authorities tab → Import +3. Select `ca.crt` and trust for websites + +## Verify Installation + +Run the health check to verify everything is configured correctly: + +```bash +katana doctor +``` + +All checks should pass: + +``` +Katana Health Check +=================== + +✓ Docker daemon running +✓ User has Docker permissions +✓ Docker network 'katana-net' exists +✓ OpenSSL available +✓ Certificates initialized +✓ Certificates valid (expires in 364 days) +✓ Port 443 bindable +✓ DNS entries in sync (8/8) +✓ State file valid + +Health: 9/9 checks passed +``` + +## Your First Target + +Install and access a vulnerable web application: + +```bash +# Install DVWA (Damn Vulnerable Web Application) +katana install dvwa + +# Start the proxy server +katana proxy start +``` + +Open your browser and visit: `https://dvwa.samurai.wtf` + +The proxy runs in the foreground. Press `Ctrl+C` to stop it. + +## Dashboard Access + +With the proxy running, access the web dashboard at: + +``` +https://katana.samurai.wtf +``` + +From the dashboard you can: +- View all available targets +- Install, start, stop, and remove targets +- Check system status +- Download the CA certificate + +## Next Steps + +- [CLI Reference](cli-reference.md) - Learn all available commands +- [Deployment Guide](deployment-guide.md) - Set up for local or cloud use +- [Troubleshooting](troubleshooting.md) - Common issues and solutions diff --git a/docs/hyper-v-setup.md b/docs/hyper-v-setup.md new file mode 100644 index 0000000..bb6be43 --- /dev/null +++ b/docs/hyper-v-setup.md @@ -0,0 +1,294 @@ +# Hyper-V Ubuntu Desktop Setup for Katana Testing + +This guide walks through setting up Ubuntu Desktop on Hyper-V for local Katana development and testing. + +## Step 1: Download Ubuntu Desktop ISO + +1. Go to https://ubuntu.com/download/desktop +2. Download **Ubuntu 24.04 LTS Desktop** (or 22.04 LTS if preferred) +3. Save the ISO file (approximately 5-6 GB) + +## Step 2: Create Hyper-V Virtual Machine + +### Open Hyper-V Manager +- Press Windows key, type "Hyper-V Manager", and open it +- If you don't see it, you may need to enable Hyper-V feature in Windows Features + +### Create New Virtual Machine +1. In Hyper-V Manager, click **Action → New → Virtual Machine** +2. Click **Next** on the welcome screen + +### Configure the VM: + +**Specify Name and Location:** +- Name: `Katana-Ubuntu-Test` (or whatever you prefer) +- Click **Next** + +**Specify Generation:** +- Select **Generation 2** (required for Ubuntu) +- Click **Next** + +**Assign Memory:** +- Startup memory: `4096` MB (4 GB minimum, 8 GB better if you have RAM to spare) +- Check **Use Dynamic Memory** +- Click **Next** + +**Configure Networking:** +- Connection: Select **Default Switch** (this gives the VM internet access and makes it reachable from Windows) +- Click **Next** + +**Connect Virtual Hard Disk:** +- Create a virtual hard disk +- Name: `Katana-Ubuntu-Test.vhdx` +- Size: `50` GB (minimum) +- Click **Next** + +**Installation Options:** +- Select **Install an operating system from a bootable image file** +- Click **Browse** and select the Ubuntu ISO you downloaded +- Click **Next** + +**Finish:** +- Review your settings +- Click **Finish** + +### Configure VM Settings (Before First Boot) + +1. Right-click your new VM → **Settings** + +2. **Security** (left sidebar): + - **UNCHECK** "Enable Secure Boot" + - (Ubuntu can work with Secure Boot, but disabling avoids potential issues) + - Click **OK** + +3. **Processor** (left sidebar): + - Number of virtual processors: `2` (or more if available) + +4. Click **OK** + +## Step 3: Install Ubuntu Desktop + +1. In Hyper-V Manager, double-click your VM (or right-click → **Connect**) +2. Click **Start** in the VM window +3. Ubuntu installer will boot + +### Installation Steps: +1. Select language → **English** → **Install Ubuntu** +2. Keyboard layout → **English (US)** → **Continue** +3. Updates and other software: + - Select **Normal installation** + - Check **Download updates while installing Ubuntu** + - Check **Install third-party software** (for better hardware support) + - **Continue** +4. Installation type → **Erase disk and install Ubuntu** → **Install Now** → **Continue** + - (Don't worry, this only affects the virtual disk, not your Windows machine) +5. Time zone → Select your timezone → **Continue** +6. Create your user: + - Your name: (your name) + - Computer name: `katana-test` (or whatever you prefer) + - Username: (your username) + - Password: (choose a password) + - **Continue** +7. Wait for installation (10-15 minutes) +8. Click **Restart Now** +9. Press Enter when prompted to remove installation medium +10. VM will reboot into Ubuntu + +### Initial Ubuntu Setup: +1. Log in with your password +2. Click through the "What's New" screens +3. Skip Ubuntu Pro setup (or sign up if you want) +4. Skip sending system info +5. You now have Ubuntu Desktop running! + +## Step 4: Enable Enhanced Session Mode (Better Display/Clipboard) + +Enhanced Session Mode gives you better resolution, clipboard sharing, and easier interaction. + +### In the Ubuntu VM: + +1. Open Terminal (Ctrl+Alt+T) + +2. Run this script to install Enhanced Session Mode: +```bash +# Download and run the Enhanced Session script +wget https://raw.githubusercontent.com/Hinara/linux-vm-tools/ubuntu20-04/ubuntu/22.04/install.sh +chmod +x install.sh +sudo ./install.sh +``` + +3. When prompted: + - Enter your password + - Press **Y** to continue + - The script will install xrdp and configure Enhanced Session + +4. Reboot the VM: +```bash +sudo reboot +``` + +### On Windows (Hyper-V Host): + +1. Open PowerShell **as Administrator** + +2. Run this command to enable Enhanced Session for your VM: +```powershell +Set-VM -VMName "Katana-Ubuntu-Test" -EnhancedSessionTransportType HvSocket +``` +(Replace "Katana-Ubuntu-Test" with your VM name if different) + +### Connect with Enhanced Session: + +1. In Hyper-V Manager, double-click your VM to connect +2. You should see a connection options dialog with resolution choices +3. Select your desired resolution → **Connect** +4. Log in when prompted + +You should now have a better desktop experience with copy/paste working between Windows and Ubuntu! + +## Step 5: Install Docker + +In your Ubuntu VM, open Terminal and run these commands: + +```bash +# Update package list +sudo apt update + +# Install prerequisites +sudo apt install -y ca-certificates curl + +# Add Docker's official GPG key +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# Add Docker repository +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Update package list again +sudo apt update + +# Install Docker +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Add your user to docker group (so you don't need sudo for docker commands) +sudo usermod -aG docker $USER + +# Log out and back in for group change to take effect +echo "Docker installed! Log out and back in for group membership to take effect." +``` + +**Important:** After running these commands, log out and log back in (or reboot) for the Docker group membership to take effect. + +Verify Docker is working: +```bash +docker --version +docker compose version +``` + +## Step 6: Install Bun (for Katana Development) + +```bash +# Install Bun +curl -fsSL https://bun.sh/install | bash + +# Reload your shell +source ~/.bashrc + +# Verify +bun --version +``` + +## Step 7: Clone Katana Repository + +If you want to develop Katana inside the VM: + +```bash +# Install git if needed +sudo apt install -y git + +# Clone the repository +cd ~ +git clone https://github.com/SamuraiWTF/katana.git +cd katana + +# Install dependencies +bun install +``` + +Alternatively, you can share your Windows development directory with the VM using Hyper-V shared folders, but mounting the repo inside the VM is simpler. + +## Step 8: Test Katana + +Now you can test Katana in the local scenario: + +```bash +cd ~/katana + +# Run from source +bun run src/cli.ts --help + +# Initialize certificates +bun run src/cli.ts cert init + +# Setup privileged port binding +sudo bun run src/cli.ts setup-proxy + +# Install a target +bun run src/cli.ts install dvwa + +# Sync DNS (this updates /etc/hosts) +sudo bun run src/cli.ts dns sync --all + +# Start the proxy +bun run src/cli.ts proxy start +``` + +Open Firefox in the Ubuntu VM and navigate to: +1. Export CA cert: `bun run src/cli.ts cert export` +2. Import the CA cert into Firefox +3. Visit `https://katana.samurai.wtf` +4. Visit `https://dvwa.samurai.wtf` + +Everything should work as designed! + +## Troubleshooting + +### VM won't boot / Secure Boot error +- Go to VM Settings → Security → Disable Secure Boot + +### Can't connect with Enhanced Session +- Make sure you ran the install script in the VM +- Make sure you ran the PowerShell command on the Windows host +- Try rebooting both the VM and Windows host + +### VM has no internet +- Check that VM is connected to "Default Switch" in VM Settings → Network Adapter +- Try: `sudo dhclient -r && sudo dhclient` in the VM to renew IP + +### Docker permission denied +- Make sure you logged out and back in after adding yourself to docker group +- Check: `groups` should show "docker" in the list + +### Need to access files from Windows +- You can use Hyper-V shared folders, or +- Use git to push/pull changes, or +- Set up SSH and use scp/sftp from Windows + +## Tips + +- **Snapshot your VM** after Docker installation so you can roll back if needed +- **Pause the VM** when not testing to save RAM +- **Export the VM** if you want to share the setup with others + +## Next Steps + +Once everything is working, you can: +- Test all Katana targets +- Test the full install/start/stop/remove lifecycle +- Export and import CA certificates +- Test the web dashboard +- Document any bugs you find diff --git a/docs/module-development.md b/docs/module-development.md new file mode 100644 index 0000000..4e0a1b1 --- /dev/null +++ b/docs/module-development.md @@ -0,0 +1,404 @@ +# Module Development Guide + +This guide explains how to create new target and tool modules for Katana. + +## Module Types + +Katana supports two types of modules: + +| Type | Description | Implementation | +|------|-------------|----------------| +| **Targets** | Vulnerable web applications | Docker Compose | +| **Tools** | Security testing tools | Shell scripts | + +## Target Modules + +Targets are Docker-based vulnerable web applications accessed through Katana's reverse proxy. + +### Directory Structure + +``` +modules/targets// +├── module.yml # Module metadata and proxy configuration +└── compose.yml # Docker Compose configuration +``` + +### module.yml Format + +```yaml +name: example-target +category: targets +description: Short description of the target + +compose: ./compose.yml + +proxy: + - hostname: example # Subdomain (becomes example.samurai.wtf or example.domain.com) + service: web # Docker Compose service name + port: 80 # Container port to proxy to + +# Optional: Environment variables for compose.yml +env: + SOME_VAR: value +``` + +**Fields:** + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique module name (lowercase, alphanumeric, hyphens) | +| `category` | Yes | Must be `targets` | +| `description` | Yes | Brief description | +| `compose` | Yes | Path to Docker Compose file | +| `proxy` | Yes | Array of proxy route configurations | +| `env` | No | Environment variables passed to Docker Compose | + +### Proxy Configuration + +Each proxy entry maps a hostname to a container port: + +```yaml +proxy: + - hostname: dvwa # https://dvwa.samurai.wtf + service: web # Service name in compose.yml + port: 80 # Port the service listens on +``` + +**Multi-hostname targets** (like Musashi) can map multiple hostnames to different ports on the same container: + +```yaml +proxy: + - hostname: cors + service: musashi + port: 3021 + - hostname: api-cors + service: musashi + port: 3020 + - hostname: csp + service: musashi + port: 3041 +``` + +### compose.yml Requirements + +```yaml +services: + web: + image: vulnerables/web-dvwa + networks: + - katana-net + # Optional: environment variables + environment: + - DB_HOST=db + # NO ports: section - proxy handles external access + + # Optional: additional services (databases, etc.) + db: + image: mariadb:10.6 + networks: + - katana-net + environment: + - MYSQL_ROOT_PASSWORD=dvwa + +networks: + katana-net: + external: true +``` + +**Rules:** + +1. **Must join `katana-net`** - All services must be on the external `katana-net` network +2. **No published ports** - Do not use `ports:` section; the proxy handles external access +3. **Use official/trusted images** - Prefer images from Docker Hub official repositories or verified publishers + +### Environment Variable Templating + +The `env` section in `module.yml` is passed to Docker Compose. You can use this for runtime configuration: + +**module.yml:** +```yaml +env: + API_HOST: api.samurai.wtf + CLIENT_HOST: client.samurai.wtf +``` + +**compose.yml:** +```yaml +services: + app: + image: example/app + environment: + - API_URL=https://${API_HOST} + - CLIENT_URL=https://${CLIENT_HOST} +``` + +### Example: Simple Target (Juice Shop) + +**modules/targets/juiceshop/module.yml:** +```yaml +name: juiceshop +category: targets +description: OWASP Juice Shop - Modern vulnerable web application + +compose: ./compose.yml + +proxy: + - hostname: juiceshop + service: juiceshop + port: 3000 +``` + +**modules/targets/juiceshop/compose.yml:** +```yaml +services: + juiceshop: + image: bkimminich/juice-shop + networks: + - katana-net + +networks: + katana-net: + external: true +``` + +### Example: Target with Database (DVWA) + +**modules/targets/dvwa/module.yml:** +```yaml +name: dvwa +category: targets +description: Damn Vulnerable Web Application - OWASP Top 10 training + +compose: ./compose.yml + +proxy: + - hostname: dvwa + service: dvwa + port: 80 +``` + +**modules/targets/dvwa/compose.yml:** +```yaml +services: + dvwa: + image: ghcr.io/digininja/dvwa:latest + depends_on: + - db + environment: + - DB_SERVER=db + networks: + - katana-net + + db: + image: mariadb:10.6 + environment: + - MYSQL_ROOT_PASSWORD=dvwa + - MYSQL_DATABASE=dvwa + - MYSQL_USER=dvwa + - MYSQL_PASSWORD=dvwa + networks: + - katana-net + +networks: + katana-net: + external: true +``` + +### Example: Multi-Hostname Target (Musashi) + +**modules/targets/musashi/module.yml:** +```yaml +name: musashi +category: targets +description: Musashi.js - CORS, CSP, and JWT security demonstrations + +compose: ./compose.yml + +proxy: + - hostname: cors + service: musashi + port: 3021 + - hostname: api-cors + service: musashi + port: 3020 + - hostname: csp + service: musashi + port: 3041 + - hostname: jwt + service: musashi + port: 3050 + +env: + CORS_CLIENT_HOST: cors.samurai.wtf + CORS_API_HOST: api-cors.samurai.wtf + CSP_HOST: csp.samurai.wtf + JWT_HOST: jwt.samurai.wtf +``` + +--- + +## Tool Modules + +Tools are security applications installed via shell scripts. They're primarily for local (VM) deployments. + +### Directory Structure + +``` +modules/tools// +├── module.yml +├── install.sh +├── remove.sh +├── start.sh # Optional +└── stop.sh # Optional +``` + +### module.yml Format + +```yaml +name: example-tool +category: tools +description: Description of the tool + +install: ./install.sh +remove: ./remove.sh +start: ./start.sh # Optional +stop: ./stop.sh # Optional +install_requires_root: true # Set if install needs sudo +``` + +### Script Requirements + +Scripts must: +- Be executable (`chmod +x`) +- Exit 0 on success, non-zero on failure +- Use `set -e` for fail-fast behavior + +**Example install.sh:** +```bash +#!/bin/bash +set -e + +VERSION="2.14.0" +URL="https://github.com/zaproxy/zaproxy/releases/download/v${VERSION}/ZAP_${VERSION}_Linux.tar.gz" +CHECKSUM="abc123..." # SHA256 + +cd /tmp +wget -q "$URL" -O zap.tar.gz +echo "${CHECKSUM} zap.tar.gz" | sha256sum -c +tar xzf zap.tar.gz -C /opt/ +ln -sf /opt/ZAP_${VERSION}/zap.sh /usr/local/bin/zap +rm zap.tar.gz + +echo "ZAP ${VERSION} installed successfully" +``` + +**Example remove.sh:** +```bash +#!/bin/bash +set -e + +rm -rf /opt/ZAP_* +rm -f /usr/local/bin/zap + +echo "ZAP removed successfully" +``` + +--- + +## Testing Modules + +### 1. Validate Module Structure + +```bash +# Check that module loads without errors +katana list targets +``` + +### 2. Test Installation + +```bash +# Install the target +katana install + +# Check status +katana status +docker ps | grep katana- +``` + +### 3. Test Proxy Access + +```bash +# Ensure DNS is synced +sudo katana dns sync + +# Start proxy +katana proxy start + +# Test in browser or with curl +curl -k https://.samurai.wtf +``` + +### 4. Test Removal + +```bash +# Remove the target +katana remove + +# Verify containers removed +docker ps -a | grep katana- +``` + +--- + +## Contributing Modules + +To contribute a new module: + +1. **Create the module** in `modules/targets//` or `modules/tools//` + +2. **Test thoroughly** using the steps above + +3. **Open a Pull Request** with: + - The module files + - Brief description of the target/tool + - Any special setup requirements + +### Guidelines + +- Use official Docker images when possible +- Include meaningful descriptions +- Test on a clean system +- Document any special requirements in the PR + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for general contribution guidelines. + +--- + +## Troubleshooting Module Issues + +### Container Won't Start + +Check container logs: +```bash +katana logs +docker compose -p katana- logs +``` + +### Proxy Returns 502/503 + +1. Verify container is running: `docker ps | grep ` +2. Check the service name matches `module.yml` +3. Check the port number is correct + +### Module Not Listed + +1. Verify `module.yml` syntax is valid YAML +2. Check all required fields are present +3. Ensure file is in correct directory (`modules/targets//`) + +### Network Issues + +Verify container is on katana-net: +```bash +docker inspect katana---1 | grep -A 10 Networks +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..76db555 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,341 @@ +# Troubleshooting + +Solutions for common issues when using Katana. + +## Using `katana doctor` + +The first step for any issue should be running the health check: + +```bash +katana doctor +``` + +This checks 9 system requirements and provides fix suggestions for any failures. + +Example output with issues: + +``` +Katana Health Check +=================== + +✓ Docker daemon running +✗ User has Docker permissions + → Fix: sudo usermod -aG docker $USER && newgrp docker +✓ Docker network 'katana-net' exists +✓ OpenSSL available +✓ Certificates initialized +✓ Certificates valid (expires in 364 days) +✗ Port 443 bindable + → Fix: sudo katana setup-proxy +✓ DNS entries in sync (8/8) +✓ State file valid + +Health: 7/9 checks passed +``` + +--- + +## Common Errors + +### Docker Permission Denied + +**Error:** +``` +Permission denied accessing Docker socket +``` + +**Cause:** Your user is not in the `docker` group. + +**Fix:** +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +Then log out and back in, or run `newgrp docker` to apply the change immediately. + +--- + +### Port 443 Bind Failed + +**Error:** +``` +Permission denied binding to port 443 +``` + +**Cause:** The Katana binary doesn't have the capability to bind to privileged ports. + +**Fix:** +```bash +sudo katana setup-proxy +``` + +**Note:** This must be re-run after updating/replacing the binary. + +--- + +### Docker Daemon Not Running + +**Error:** +``` +Docker daemon is not running +``` + +**Fix:** +```bash +sudo systemctl start docker +sudo systemctl enable docker # Auto-start on boot +``` + +--- + +### Certificate Errors + +#### Certificates Not Initialized + +**Error:** +``` +CA not initialized +``` + +**Fix:** +```bash +katana cert init +``` + +#### Certificate Expired + +**Error:** +``` +Certificates expired +``` + +**Fix:** +```bash +katana cert renew +``` + +#### Browser Shows Certificate Warning + +**Cause:** The CA certificate is not imported into your browser. + +**Fix:** +1. Export the CA: `katana cert export` +2. Import `ca.crt` into your browser (see [Getting Started](getting-started.md#5-import-ca-certificate-in-browser)) + +--- + +### DNS Not Resolving + +#### `/etc/hosts` Not Updated + +**Symptom:** Browser can't reach `https://dvwa.samurai.wtf` + +**Check:** +```bash +katana dns list +``` + +**Fix:** +```bash +sudo katana dns sync --all +``` + +#### Hostname Not in `/etc/hosts` + +**Symptom:** Target installed but hostname doesn't resolve. + +**Check:** +```bash +cat /etc/hosts | grep katana-managed +``` + +**Fix:** +```bash +sudo katana dns sync +``` + +--- + +### Target Not Accessible + +#### Container Not Running + +**Check:** +```bash +katana status +docker ps | grep katana +``` + +**Fix:** +```bash +katana start +``` + +#### Proxy Not Running + +**Symptom:** All targets inaccessible, connection refused. + +**Fix:** +```bash +katana proxy start +``` + +#### Wrong Hostname + +**Check:** Ensure you're using the correct URL format: +- Local: `https://dvwa.samurai.wtf` +- Remote: `https://dvwa.lab01.training.example.com` + +--- + +### System Locked Error + +**Error:** +``` +System is locked - cannot modify targets +``` + +**Cause:** The system was locked (typically by an instructor). + +**Fix:** +```bash +katana unlock +``` + +--- + +### Container Startup Failed + +**Symptom:** Target install succeeds but container isn't running. + +**Check:** +```bash +katana logs +docker compose -p katana- logs +``` + +Common causes: +- Port conflict with another container +- Missing Docker image (network issue during pull) +- Insufficient memory + +--- + +## Performance Issues + +### Slow Target Startup + +**Cause:** Docker images being pulled for the first time. + +**Solution:** Pre-pull images: +```bash +katana install # First install pulls images +# Subsequent starts will be faster +``` + +### High Memory Usage + +**Cause:** Too many targets running simultaneously. + +**Solution:** Stop unused targets: +```bash +katana stop +``` + +Check memory usage: +```bash +docker stats +``` + +--- + +## Recovery Procedures + +### Reset State File + +If the state file becomes corrupted: + +```bash +# Backup current state +cp ~/.local/share/katana/state.yml ~/.local/share/katana/state.yml.bak + +# Remove state file (Katana will create a new one) +rm ~/.local/share/katana/state.yml + +# Re-sync with Docker +katana cleanup +``` + +### Remove Orphaned Containers + +Containers from deleted or corrupted state: + +```bash +# See what would be cleaned up +katana cleanup --dry-run + +# Clean up +katana cleanup +``` + +### Regenerate Certificates + +If certificates are corrupted or lost: + +```bash +# Remove old certs +rm -rf ~/.local/share/katana/certs + +# Regenerate +katana cert init +``` + +**Note:** After regenerating the CA, you must re-import it into all browsers. + +### Complete Reset + +To completely reset Katana: + +```bash +# Stop all targets +katana list --installed | grep -v "Available" | awk '{print $1}' | xargs -I {} katana remove {} + +# Remove all Katana data +rm -rf ~/.local/share/katana +rm -rf ~/.config/katana + +# Remove Docker network +docker network rm katana-net + +# Re-run setup +katana cert init +sudo katana setup-proxy +sudo katana dns sync --all +``` + +--- + +## Getting Help + +If you can't resolve an issue: + +1. **Run diagnostics:** + ```bash + katana doctor --json > doctor-output.json + katana status > status-output.txt + ``` + +2. **Collect logs:** + ```bash + katana logs > target-logs.txt + ``` + +3. **Open an issue:** [GitHub Issues](https://github.com/SamuraiWTF/katana/issues) + +Include: +- Output from `katana doctor` +- Output from `katana status` +- Relevant logs +- Steps to reproduce the issue +- Your Linux distribution and version diff --git a/html/css/Bulma_LICENSE.txt b/html/css/Bulma_LICENSE.txt deleted file mode 100644 index 2c51c72..0000000 --- a/html/css/Bulma_LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2020 Jeremy Thomas - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/html/css/all.css b/html/css/all.css deleted file mode 100644 index 8ebd25f..0000000 --- a/html/css/all.css +++ /dev/null @@ -1,4556 +0,0 @@ -/*! - * Font Awesome Free 5.13.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) - */ -.fa, -.fas, -.far, -.fal, -.fad, -.fab { - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - display: inline-block; - font-style: normal; - font-variant: normal; - text-rendering: auto; - line-height: 1; } - -.fa-lg { - font-size: 1.33333em; - line-height: 0.75em; - vertical-align: -.0667em; } - -.fa-xs { - font-size: .75em; } - -.fa-sm { - font-size: .875em; } - -.fa-1x { - font-size: 1em; } - -.fa-2x { - font-size: 2em; } - -.fa-3x { - font-size: 3em; } - -.fa-4x { - font-size: 4em; } - -.fa-5x { - font-size: 5em; } - -.fa-6x { - font-size: 6em; } - -.fa-7x { - font-size: 7em; } - -.fa-8x { - font-size: 8em; } - -.fa-9x { - font-size: 9em; } - -.fa-10x { - font-size: 10em; } - -.fa-fw { - text-align: center; - width: 1.25em; } - -.fa-ul { - list-style-type: none; - margin-left: 2.5em; - padding-left: 0; } - .fa-ul > li { - position: relative; } - -.fa-li { - left: -2em; - position: absolute; - text-align: center; - width: 2em; - line-height: inherit; } - -.fa-border { - border: solid 0.08em #eee; - border-radius: .1em; - padding: .2em .25em .15em; } - -.fa-pull-left { - float: left; } - -.fa-pull-right { - float: right; } - -.fa.fa-pull-left, -.fas.fa-pull-left, -.far.fa-pull-left, -.fal.fa-pull-left, -.fab.fa-pull-left { - margin-right: .3em; } - -.fa.fa-pull-right, -.fas.fa-pull-right, -.far.fa-pull-right, -.fal.fa-pull-right, -.fab.fa-pull-right { - margin-left: .3em; } - -.fa-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; } - -.fa-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); } - -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); } - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); } } - -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); } - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); } } - -.fa-rotate-90 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; - -webkit-transform: rotate(90deg); - transform: rotate(90deg); } - -.fa-rotate-180 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; - -webkit-transform: rotate(180deg); - transform: rotate(180deg); } - -.fa-rotate-270 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; - -webkit-transform: rotate(270deg); - transform: rotate(270deg); } - -.fa-flip-horizontal { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; - -webkit-transform: scale(-1, 1); - transform: scale(-1, 1); } - -.fa-flip-vertical { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; - -webkit-transform: scale(1, -1); - transform: scale(1, -1); } - -.fa-flip-both, .fa-flip-horizontal.fa-flip-vertical { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; - -webkit-transform: scale(-1, -1); - transform: scale(-1, -1); } - -:root .fa-rotate-90, -:root .fa-rotate-180, -:root .fa-rotate-270, -:root .fa-flip-horizontal, -:root .fa-flip-vertical, -:root .fa-flip-both { - -webkit-filter: none; - filter: none; } - -.fa-stack { - display: inline-block; - height: 2em; - line-height: 2em; - position: relative; - vertical-align: middle; - width: 2.5em; } - -.fa-stack-1x, -.fa-stack-2x { - left: 0; - position: absolute; - text-align: center; - width: 100%; } - -.fa-stack-1x { - line-height: inherit; } - -.fa-stack-2x { - font-size: 2em; } - -.fa-inverse { - color: #fff; } - -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen -readers do not read off random characters that represent icons */ -.fa-500px:before { - content: "\f26e"; } - -.fa-accessible-icon:before { - content: "\f368"; } - -.fa-accusoft:before { - content: "\f369"; } - -.fa-acquisitions-incorporated:before { - content: "\f6af"; } - -.fa-ad:before { - content: "\f641"; } - -.fa-address-book:before { - content: "\f2b9"; } - -.fa-address-card:before { - content: "\f2bb"; } - -.fa-adjust:before { - content: "\f042"; } - -.fa-adn:before { - content: "\f170"; } - -.fa-adobe:before { - content: "\f778"; } - -.fa-adversal:before { - content: "\f36a"; } - -.fa-affiliatetheme:before { - content: "\f36b"; } - -.fa-air-freshener:before { - content: "\f5d0"; } - -.fa-airbnb:before { - content: "\f834"; } - -.fa-algolia:before { - content: "\f36c"; } - -.fa-align-center:before { - content: "\f037"; } - -.fa-align-justify:before { - content: "\f039"; } - -.fa-align-left:before { - content: "\f036"; } - -.fa-align-right:before { - content: "\f038"; } - -.fa-alipay:before { - content: "\f642"; } - -.fa-allergies:before { - content: "\f461"; } - -.fa-amazon:before { - content: "\f270"; } - -.fa-amazon-pay:before { - content: "\f42c"; } - -.fa-ambulance:before { - content: "\f0f9"; } - -.fa-american-sign-language-interpreting:before { - content: "\f2a3"; } - -.fa-amilia:before { - content: "\f36d"; } - -.fa-anchor:before { - content: "\f13d"; } - -.fa-android:before { - content: "\f17b"; } - -.fa-angellist:before { - content: "\f209"; } - -.fa-angle-double-down:before { - content: "\f103"; } - -.fa-angle-double-left:before { - content: "\f100"; } - -.fa-angle-double-right:before { - content: "\f101"; } - -.fa-angle-double-up:before { - content: "\f102"; } - -.fa-angle-down:before { - content: "\f107"; } - -.fa-angle-left:before { - content: "\f104"; } - -.fa-angle-right:before { - content: "\f105"; } - -.fa-angle-up:before { - content: "\f106"; } - -.fa-angry:before { - content: "\f556"; } - -.fa-angrycreative:before { - content: "\f36e"; } - -.fa-angular:before { - content: "\f420"; } - -.fa-ankh:before { - content: "\f644"; } - -.fa-app-store:before { - content: "\f36f"; } - -.fa-app-store-ios:before { - content: "\f370"; } - -.fa-apper:before { - content: "\f371"; } - -.fa-apple:before { - content: "\f179"; } - -.fa-apple-alt:before { - content: "\f5d1"; } - -.fa-apple-pay:before { - content: "\f415"; } - -.fa-archive:before { - content: "\f187"; } - -.fa-archway:before { - content: "\f557"; } - -.fa-arrow-alt-circle-down:before { - content: "\f358"; } - -.fa-arrow-alt-circle-left:before { - content: "\f359"; } - -.fa-arrow-alt-circle-right:before { - content: "\f35a"; } - -.fa-arrow-alt-circle-up:before { - content: "\f35b"; } - -.fa-arrow-circle-down:before { - content: "\f0ab"; } - -.fa-arrow-circle-left:before { - content: "\f0a8"; } - -.fa-arrow-circle-right:before { - content: "\f0a9"; } - -.fa-arrow-circle-up:before { - content: "\f0aa"; } - -.fa-arrow-down:before { - content: "\f063"; } - -.fa-arrow-left:before { - content: "\f060"; } - -.fa-arrow-right:before { - content: "\f061"; } - -.fa-arrow-up:before { - content: "\f062"; } - -.fa-arrows-alt:before { - content: "\f0b2"; } - -.fa-arrows-alt-h:before { - content: "\f337"; } - -.fa-arrows-alt-v:before { - content: "\f338"; } - -.fa-artstation:before { - content: "\f77a"; } - -.fa-assistive-listening-systems:before { - content: "\f2a2"; } - -.fa-asterisk:before { - content: "\f069"; } - -.fa-asymmetrik:before { - content: "\f372"; } - -.fa-at:before { - content: "\f1fa"; } - -.fa-atlas:before { - content: "\f558"; } - -.fa-atlassian:before { - content: "\f77b"; } - -.fa-atom:before { - content: "\f5d2"; } - -.fa-audible:before { - content: "\f373"; } - -.fa-audio-description:before { - content: "\f29e"; } - -.fa-autoprefixer:before { - content: "\f41c"; } - -.fa-avianex:before { - content: "\f374"; } - -.fa-aviato:before { - content: "\f421"; } - -.fa-award:before { - content: "\f559"; } - -.fa-aws:before { - content: "\f375"; } - -.fa-baby:before { - content: "\f77c"; } - -.fa-baby-carriage:before { - content: "\f77d"; } - -.fa-backspace:before { - content: "\f55a"; } - -.fa-backward:before { - content: "\f04a"; } - -.fa-bacon:before { - content: "\f7e5"; } - -.fa-bahai:before { - content: "\f666"; } - -.fa-balance-scale:before { - content: "\f24e"; } - -.fa-balance-scale-left:before { - content: "\f515"; } - -.fa-balance-scale-right:before { - content: "\f516"; } - -.fa-ban:before { - content: "\f05e"; } - -.fa-band-aid:before { - content: "\f462"; } - -.fa-bandcamp:before { - content: "\f2d5"; } - -.fa-barcode:before { - content: "\f02a"; } - -.fa-bars:before { - content: "\f0c9"; } - -.fa-baseball-ball:before { - content: "\f433"; } - -.fa-basketball-ball:before { - content: "\f434"; } - -.fa-bath:before { - content: "\f2cd"; } - -.fa-battery-empty:before { - content: "\f244"; } - -.fa-battery-full:before { - content: "\f240"; } - -.fa-battery-half:before { - content: "\f242"; } - -.fa-battery-quarter:before { - content: "\f243"; } - -.fa-battery-three-quarters:before { - content: "\f241"; } - -.fa-battle-net:before { - content: "\f835"; } - -.fa-bed:before { - content: "\f236"; } - -.fa-beer:before { - content: "\f0fc"; } - -.fa-behance:before { - content: "\f1b4"; } - -.fa-behance-square:before { - content: "\f1b5"; } - -.fa-bell:before { - content: "\f0f3"; } - -.fa-bell-slash:before { - content: "\f1f6"; } - -.fa-bezier-curve:before { - content: "\f55b"; } - -.fa-bible:before { - content: "\f647"; } - -.fa-bicycle:before { - content: "\f206"; } - -.fa-biking:before { - content: "\f84a"; } - -.fa-bimobject:before { - content: "\f378"; } - -.fa-binoculars:before { - content: "\f1e5"; } - -.fa-biohazard:before { - content: "\f780"; } - -.fa-birthday-cake:before { - content: "\f1fd"; } - -.fa-bitbucket:before { - content: "\f171"; } - -.fa-bitcoin:before { - content: "\f379"; } - -.fa-bity:before { - content: "\f37a"; } - -.fa-black-tie:before { - content: "\f27e"; } - -.fa-blackberry:before { - content: "\f37b"; } - -.fa-blender:before { - content: "\f517"; } - -.fa-blender-phone:before { - content: "\f6b6"; } - -.fa-blind:before { - content: "\f29d"; } - -.fa-blog:before { - content: "\f781"; } - -.fa-blogger:before { - content: "\f37c"; } - -.fa-blogger-b:before { - content: "\f37d"; } - -.fa-bluetooth:before { - content: "\f293"; } - -.fa-bluetooth-b:before { - content: "\f294"; } - -.fa-bold:before { - content: "\f032"; } - -.fa-bolt:before { - content: "\f0e7"; } - -.fa-bomb:before { - content: "\f1e2"; } - -.fa-bone:before { - content: "\f5d7"; } - -.fa-bong:before { - content: "\f55c"; } - -.fa-book:before { - content: "\f02d"; } - -.fa-book-dead:before { - content: "\f6b7"; } - -.fa-book-medical:before { - content: "\f7e6"; } - -.fa-book-open:before { - content: "\f518"; } - -.fa-book-reader:before { - content: "\f5da"; } - -.fa-bookmark:before { - content: "\f02e"; } - -.fa-bootstrap:before { - content: "\f836"; } - -.fa-border-all:before { - content: "\f84c"; } - -.fa-border-none:before { - content: "\f850"; } - -.fa-border-style:before { - content: "\f853"; } - -.fa-bowling-ball:before { - content: "\f436"; } - -.fa-box:before { - content: "\f466"; } - -.fa-box-open:before { - content: "\f49e"; } - -.fa-box-tissue:before { - content: "\f95b"; } - -.fa-boxes:before { - content: "\f468"; } - -.fa-braille:before { - content: "\f2a1"; } - -.fa-brain:before { - content: "\f5dc"; } - -.fa-bread-slice:before { - content: "\f7ec"; } - -.fa-briefcase:before { - content: "\f0b1"; } - -.fa-briefcase-medical:before { - content: "\f469"; } - -.fa-broadcast-tower:before { - content: "\f519"; } - -.fa-broom:before { - content: "\f51a"; } - -.fa-brush:before { - content: "\f55d"; } - -.fa-btc:before { - content: "\f15a"; } - -.fa-buffer:before { - content: "\f837"; } - -.fa-bug:before { - content: "\f188"; } - -.fa-building:before { - content: "\f1ad"; } - -.fa-bullhorn:before { - content: "\f0a1"; } - -.fa-bullseye:before { - content: "\f140"; } - -.fa-burn:before { - content: "\f46a"; } - -.fa-buromobelexperte:before { - content: "\f37f"; } - -.fa-bus:before { - content: "\f207"; } - -.fa-bus-alt:before { - content: "\f55e"; } - -.fa-business-time:before { - content: "\f64a"; } - -.fa-buy-n-large:before { - content: "\f8a6"; } - -.fa-buysellads:before { - content: "\f20d"; } - -.fa-calculator:before { - content: "\f1ec"; } - -.fa-calendar:before { - content: "\f133"; } - -.fa-calendar-alt:before { - content: "\f073"; } - -.fa-calendar-check:before { - content: "\f274"; } - -.fa-calendar-day:before { - content: "\f783"; } - -.fa-calendar-minus:before { - content: "\f272"; } - -.fa-calendar-plus:before { - content: "\f271"; } - -.fa-calendar-times:before { - content: "\f273"; } - -.fa-calendar-week:before { - content: "\f784"; } - -.fa-camera:before { - content: "\f030"; } - -.fa-camera-retro:before { - content: "\f083"; } - -.fa-campground:before { - content: "\f6bb"; } - -.fa-canadian-maple-leaf:before { - content: "\f785"; } - -.fa-candy-cane:before { - content: "\f786"; } - -.fa-cannabis:before { - content: "\f55f"; } - -.fa-capsules:before { - content: "\f46b"; } - -.fa-car:before { - content: "\f1b9"; } - -.fa-car-alt:before { - content: "\f5de"; } - -.fa-car-battery:before { - content: "\f5df"; } - -.fa-car-crash:before { - content: "\f5e1"; } - -.fa-car-side:before { - content: "\f5e4"; } - -.fa-caravan:before { - content: "\f8ff"; } - -.fa-caret-down:before { - content: "\f0d7"; } - -.fa-caret-left:before { - content: "\f0d9"; } - -.fa-caret-right:before { - content: "\f0da"; } - -.fa-caret-square-down:before { - content: "\f150"; } - -.fa-caret-square-left:before { - content: "\f191"; } - -.fa-caret-square-right:before { - content: "\f152"; } - -.fa-caret-square-up:before { - content: "\f151"; } - -.fa-caret-up:before { - content: "\f0d8"; } - -.fa-carrot:before { - content: "\f787"; } - -.fa-cart-arrow-down:before { - content: "\f218"; } - -.fa-cart-plus:before { - content: "\f217"; } - -.fa-cash-register:before { - content: "\f788"; } - -.fa-cat:before { - content: "\f6be"; } - -.fa-cc-amazon-pay:before { - content: "\f42d"; } - -.fa-cc-amex:before { - content: "\f1f3"; } - -.fa-cc-apple-pay:before { - content: "\f416"; } - -.fa-cc-diners-club:before { - content: "\f24c"; } - -.fa-cc-discover:before { - content: "\f1f2"; } - -.fa-cc-jcb:before { - content: "\f24b"; } - -.fa-cc-mastercard:before { - content: "\f1f1"; } - -.fa-cc-paypal:before { - content: "\f1f4"; } - -.fa-cc-stripe:before { - content: "\f1f5"; } - -.fa-cc-visa:before { - content: "\f1f0"; } - -.fa-centercode:before { - content: "\f380"; } - -.fa-centos:before { - content: "\f789"; } - -.fa-certificate:before { - content: "\f0a3"; } - -.fa-chair:before { - content: "\f6c0"; } - -.fa-chalkboard:before { - content: "\f51b"; } - -.fa-chalkboard-teacher:before { - content: "\f51c"; } - -.fa-charging-station:before { - content: "\f5e7"; } - -.fa-chart-area:before { - content: "\f1fe"; } - -.fa-chart-bar:before { - content: "\f080"; } - -.fa-chart-line:before { - content: "\f201"; } - -.fa-chart-pie:before { - content: "\f200"; } - -.fa-check:before { - content: "\f00c"; } - -.fa-check-circle:before { - content: "\f058"; } - -.fa-check-double:before { - content: "\f560"; } - -.fa-check-square:before { - content: "\f14a"; } - -.fa-cheese:before { - content: "\f7ef"; } - -.fa-chess:before { - content: "\f439"; } - -.fa-chess-bishop:before { - content: "\f43a"; } - -.fa-chess-board:before { - content: "\f43c"; } - -.fa-chess-king:before { - content: "\f43f"; } - -.fa-chess-knight:before { - content: "\f441"; } - -.fa-chess-pawn:before { - content: "\f443"; } - -.fa-chess-queen:before { - content: "\f445"; } - -.fa-chess-rook:before { - content: "\f447"; } - -.fa-chevron-circle-down:before { - content: "\f13a"; } - -.fa-chevron-circle-left:before { - content: "\f137"; } - -.fa-chevron-circle-right:before { - content: "\f138"; } - -.fa-chevron-circle-up:before { - content: "\f139"; } - -.fa-chevron-down:before { - content: "\f078"; } - -.fa-chevron-left:before { - content: "\f053"; } - -.fa-chevron-right:before { - content: "\f054"; } - -.fa-chevron-up:before { - content: "\f077"; } - -.fa-child:before { - content: "\f1ae"; } - -.fa-chrome:before { - content: "\f268"; } - -.fa-chromecast:before { - content: "\f838"; } - -.fa-church:before { - content: "\f51d"; } - -.fa-circle:before { - content: "\f111"; } - -.fa-circle-notch:before { - content: "\f1ce"; } - -.fa-city:before { - content: "\f64f"; } - -.fa-clinic-medical:before { - content: "\f7f2"; } - -.fa-clipboard:before { - content: "\f328"; } - -.fa-clipboard-check:before { - content: "\f46c"; } - -.fa-clipboard-list:before { - content: "\f46d"; } - -.fa-clock:before { - content: "\f017"; } - -.fa-clone:before { - content: "\f24d"; } - -.fa-closed-captioning:before { - content: "\f20a"; } - -.fa-cloud:before { - content: "\f0c2"; } - -.fa-cloud-download-alt:before { - content: "\f381"; } - -.fa-cloud-meatball:before { - content: "\f73b"; } - -.fa-cloud-moon:before { - content: "\f6c3"; } - -.fa-cloud-moon-rain:before { - content: "\f73c"; } - -.fa-cloud-rain:before { - content: "\f73d"; } - -.fa-cloud-showers-heavy:before { - content: "\f740"; } - -.fa-cloud-sun:before { - content: "\f6c4"; } - -.fa-cloud-sun-rain:before { - content: "\f743"; } - -.fa-cloud-upload-alt:before { - content: "\f382"; } - -.fa-cloudscale:before { - content: "\f383"; } - -.fa-cloudsmith:before { - content: "\f384"; } - -.fa-cloudversify:before { - content: "\f385"; } - -.fa-cocktail:before { - content: "\f561"; } - -.fa-code:before { - content: "\f121"; } - -.fa-code-branch:before { - content: "\f126"; } - -.fa-codepen:before { - content: "\f1cb"; } - -.fa-codiepie:before { - content: "\f284"; } - -.fa-coffee:before { - content: "\f0f4"; } - -.fa-cog:before { - content: "\f013"; } - -.fa-cogs:before { - content: "\f085"; } - -.fa-coins:before { - content: "\f51e"; } - -.fa-columns:before { - content: "\f0db"; } - -.fa-comment:before { - content: "\f075"; } - -.fa-comment-alt:before { - content: "\f27a"; } - -.fa-comment-dollar:before { - content: "\f651"; } - -.fa-comment-dots:before { - content: "\f4ad"; } - -.fa-comment-medical:before { - content: "\f7f5"; } - -.fa-comment-slash:before { - content: "\f4b3"; } - -.fa-comments:before { - content: "\f086"; } - -.fa-comments-dollar:before { - content: "\f653"; } - -.fa-compact-disc:before { - content: "\f51f"; } - -.fa-compass:before { - content: "\f14e"; } - -.fa-compress:before { - content: "\f066"; } - -.fa-compress-alt:before { - content: "\f422"; } - -.fa-compress-arrows-alt:before { - content: "\f78c"; } - -.fa-concierge-bell:before { - content: "\f562"; } - -.fa-confluence:before { - content: "\f78d"; } - -.fa-connectdevelop:before { - content: "\f20e"; } - -.fa-contao:before { - content: "\f26d"; } - -.fa-cookie:before { - content: "\f563"; } - -.fa-cookie-bite:before { - content: "\f564"; } - -.fa-copy:before { - content: "\f0c5"; } - -.fa-copyright:before { - content: "\f1f9"; } - -.fa-cotton-bureau:before { - content: "\f89e"; } - -.fa-couch:before { - content: "\f4b8"; } - -.fa-cpanel:before { - content: "\f388"; } - -.fa-creative-commons:before { - content: "\f25e"; } - -.fa-creative-commons-by:before { - content: "\f4e7"; } - -.fa-creative-commons-nc:before { - content: "\f4e8"; } - -.fa-creative-commons-nc-eu:before { - content: "\f4e9"; } - -.fa-creative-commons-nc-jp:before { - content: "\f4ea"; } - -.fa-creative-commons-nd:before { - content: "\f4eb"; } - -.fa-creative-commons-pd:before { - content: "\f4ec"; } - -.fa-creative-commons-pd-alt:before { - content: "\f4ed"; } - -.fa-creative-commons-remix:before { - content: "\f4ee"; } - -.fa-creative-commons-sa:before { - content: "\f4ef"; } - -.fa-creative-commons-sampling:before { - content: "\f4f0"; } - -.fa-creative-commons-sampling-plus:before { - content: "\f4f1"; } - -.fa-creative-commons-share:before { - content: "\f4f2"; } - -.fa-creative-commons-zero:before { - content: "\f4f3"; } - -.fa-credit-card:before { - content: "\f09d"; } - -.fa-critical-role:before { - content: "\f6c9"; } - -.fa-crop:before { - content: "\f125"; } - -.fa-crop-alt:before { - content: "\f565"; } - -.fa-cross:before { - content: "\f654"; } - -.fa-crosshairs:before { - content: "\f05b"; } - -.fa-crow:before { - content: "\f520"; } - -.fa-crown:before { - content: "\f521"; } - -.fa-crutch:before { - content: "\f7f7"; } - -.fa-css3:before { - content: "\f13c"; } - -.fa-css3-alt:before { - content: "\f38b"; } - -.fa-cube:before { - content: "\f1b2"; } - -.fa-cubes:before { - content: "\f1b3"; } - -.fa-cut:before { - content: "\f0c4"; } - -.fa-cuttlefish:before { - content: "\f38c"; } - -.fa-d-and-d:before { - content: "\f38d"; } - -.fa-d-and-d-beyond:before { - content: "\f6ca"; } - -.fa-dailymotion:before { - content: "\f952"; } - -.fa-dashcube:before { - content: "\f210"; } - -.fa-database:before { - content: "\f1c0"; } - -.fa-deaf:before { - content: "\f2a4"; } - -.fa-delicious:before { - content: "\f1a5"; } - -.fa-democrat:before { - content: "\f747"; } - -.fa-deploydog:before { - content: "\f38e"; } - -.fa-deskpro:before { - content: "\f38f"; } - -.fa-desktop:before { - content: "\f108"; } - -.fa-dev:before { - content: "\f6cc"; } - -.fa-deviantart:before { - content: "\f1bd"; } - -.fa-dharmachakra:before { - content: "\f655"; } - -.fa-dhl:before { - content: "\f790"; } - -.fa-diagnoses:before { - content: "\f470"; } - -.fa-diaspora:before { - content: "\f791"; } - -.fa-dice:before { - content: "\f522"; } - -.fa-dice-d20:before { - content: "\f6cf"; } - -.fa-dice-d6:before { - content: "\f6d1"; } - -.fa-dice-five:before { - content: "\f523"; } - -.fa-dice-four:before { - content: "\f524"; } - -.fa-dice-one:before { - content: "\f525"; } - -.fa-dice-six:before { - content: "\f526"; } - -.fa-dice-three:before { - content: "\f527"; } - -.fa-dice-two:before { - content: "\f528"; } - -.fa-digg:before { - content: "\f1a6"; } - -.fa-digital-ocean:before { - content: "\f391"; } - -.fa-digital-tachograph:before { - content: "\f566"; } - -.fa-directions:before { - content: "\f5eb"; } - -.fa-discord:before { - content: "\f392"; } - -.fa-discourse:before { - content: "\f393"; } - -.fa-disease:before { - content: "\f7fa"; } - -.fa-divide:before { - content: "\f529"; } - -.fa-dizzy:before { - content: "\f567"; } - -.fa-dna:before { - content: "\f471"; } - -.fa-dochub:before { - content: "\f394"; } - -.fa-docker:before { - content: "\f395"; } - -.fa-dog:before { - content: "\f6d3"; } - -.fa-dollar-sign:before { - content: "\f155"; } - -.fa-dolly:before { - content: "\f472"; } - -.fa-dolly-flatbed:before { - content: "\f474"; } - -.fa-donate:before { - content: "\f4b9"; } - -.fa-door-closed:before { - content: "\f52a"; } - -.fa-door-open:before { - content: "\f52b"; } - -.fa-dot-circle:before { - content: "\f192"; } - -.fa-dove:before { - content: "\f4ba"; } - -.fa-download:before { - content: "\f019"; } - -.fa-draft2digital:before { - content: "\f396"; } - -.fa-drafting-compass:before { - content: "\f568"; } - -.fa-dragon:before { - content: "\f6d5"; } - -.fa-draw-polygon:before { - content: "\f5ee"; } - -.fa-dribbble:before { - content: "\f17d"; } - -.fa-dribbble-square:before { - content: "\f397"; } - -.fa-dropbox:before { - content: "\f16b"; } - -.fa-drum:before { - content: "\f569"; } - -.fa-drum-steelpan:before { - content: "\f56a"; } - -.fa-drumstick-bite:before { - content: "\f6d7"; } - -.fa-drupal:before { - content: "\f1a9"; } - -.fa-dumbbell:before { - content: "\f44b"; } - -.fa-dumpster:before { - content: "\f793"; } - -.fa-dumpster-fire:before { - content: "\f794"; } - -.fa-dungeon:before { - content: "\f6d9"; } - -.fa-dyalog:before { - content: "\f399"; } - -.fa-earlybirds:before { - content: "\f39a"; } - -.fa-ebay:before { - content: "\f4f4"; } - -.fa-edge:before { - content: "\f282"; } - -.fa-edit:before { - content: "\f044"; } - -.fa-egg:before { - content: "\f7fb"; } - -.fa-eject:before { - content: "\f052"; } - -.fa-elementor:before { - content: "\f430"; } - -.fa-ellipsis-h:before { - content: "\f141"; } - -.fa-ellipsis-v:before { - content: "\f142"; } - -.fa-ello:before { - content: "\f5f1"; } - -.fa-ember:before { - content: "\f423"; } - -.fa-empire:before { - content: "\f1d1"; } - -.fa-envelope:before { - content: "\f0e0"; } - -.fa-envelope-open:before { - content: "\f2b6"; } - -.fa-envelope-open-text:before { - content: "\f658"; } - -.fa-envelope-square:before { - content: "\f199"; } - -.fa-envira:before { - content: "\f299"; } - -.fa-equals:before { - content: "\f52c"; } - -.fa-eraser:before { - content: "\f12d"; } - -.fa-erlang:before { - content: "\f39d"; } - -.fa-ethereum:before { - content: "\f42e"; } - -.fa-ethernet:before { - content: "\f796"; } - -.fa-etsy:before { - content: "\f2d7"; } - -.fa-euro-sign:before { - content: "\f153"; } - -.fa-evernote:before { - content: "\f839"; } - -.fa-exchange-alt:before { - content: "\f362"; } - -.fa-exclamation:before { - content: "\f12a"; } - -.fa-exclamation-circle:before { - content: "\f06a"; } - -.fa-exclamation-triangle:before { - content: "\f071"; } - -.fa-expand:before { - content: "\f065"; } - -.fa-expand-alt:before { - content: "\f424"; } - -.fa-expand-arrows-alt:before { - content: "\f31e"; } - -.fa-expeditedssl:before { - content: "\f23e"; } - -.fa-external-link-alt:before { - content: "\f35d"; } - -.fa-external-link-square-alt:before { - content: "\f360"; } - -.fa-eye:before { - content: "\f06e"; } - -.fa-eye-dropper:before { - content: "\f1fb"; } - -.fa-eye-slash:before { - content: "\f070"; } - -.fa-facebook:before { - content: "\f09a"; } - -.fa-facebook-f:before { - content: "\f39e"; } - -.fa-facebook-messenger:before { - content: "\f39f"; } - -.fa-facebook-square:before { - content: "\f082"; } - -.fa-fan:before { - content: "\f863"; } - -.fa-fantasy-flight-games:before { - content: "\f6dc"; } - -.fa-fast-backward:before { - content: "\f049"; } - -.fa-fast-forward:before { - content: "\f050"; } - -.fa-faucet:before { - content: "\f905"; } - -.fa-fax:before { - content: "\f1ac"; } - -.fa-feather:before { - content: "\f52d"; } - -.fa-feather-alt:before { - content: "\f56b"; } - -.fa-fedex:before { - content: "\f797"; } - -.fa-fedora:before { - content: "\f798"; } - -.fa-female:before { - content: "\f182"; } - -.fa-fighter-jet:before { - content: "\f0fb"; } - -.fa-figma:before { - content: "\f799"; } - -.fa-file:before { - content: "\f15b"; } - -.fa-file-alt:before { - content: "\f15c"; } - -.fa-file-archive:before { - content: "\f1c6"; } - -.fa-file-audio:before { - content: "\f1c7"; } - -.fa-file-code:before { - content: "\f1c9"; } - -.fa-file-contract:before { - content: "\f56c"; } - -.fa-file-csv:before { - content: "\f6dd"; } - -.fa-file-download:before { - content: "\f56d"; } - -.fa-file-excel:before { - content: "\f1c3"; } - -.fa-file-export:before { - content: "\f56e"; } - -.fa-file-image:before { - content: "\f1c5"; } - -.fa-file-import:before { - content: "\f56f"; } - -.fa-file-invoice:before { - content: "\f570"; } - -.fa-file-invoice-dollar:before { - content: "\f571"; } - -.fa-file-medical:before { - content: "\f477"; } - -.fa-file-medical-alt:before { - content: "\f478"; } - -.fa-file-pdf:before { - content: "\f1c1"; } - -.fa-file-powerpoint:before { - content: "\f1c4"; } - -.fa-file-prescription:before { - content: "\f572"; } - -.fa-file-signature:before { - content: "\f573"; } - -.fa-file-upload:before { - content: "\f574"; } - -.fa-file-video:before { - content: "\f1c8"; } - -.fa-file-word:before { - content: "\f1c2"; } - -.fa-fill:before { - content: "\f575"; } - -.fa-fill-drip:before { - content: "\f576"; } - -.fa-film:before { - content: "\f008"; } - -.fa-filter:before { - content: "\f0b0"; } - -.fa-fingerprint:before { - content: "\f577"; } - -.fa-fire:before { - content: "\f06d"; } - -.fa-fire-alt:before { - content: "\f7e4"; } - -.fa-fire-extinguisher:before { - content: "\f134"; } - -.fa-firefox:before { - content: "\f269"; } - -.fa-firefox-browser:before { - content: "\f907"; } - -.fa-first-aid:before { - content: "\f479"; } - -.fa-first-order:before { - content: "\f2b0"; } - -.fa-first-order-alt:before { - content: "\f50a"; } - -.fa-firstdraft:before { - content: "\f3a1"; } - -.fa-fish:before { - content: "\f578"; } - -.fa-fist-raised:before { - content: "\f6de"; } - -.fa-flag:before { - content: "\f024"; } - -.fa-flag-checkered:before { - content: "\f11e"; } - -.fa-flag-usa:before { - content: "\f74d"; } - -.fa-flask:before { - content: "\f0c3"; } - -.fa-flickr:before { - content: "\f16e"; } - -.fa-flipboard:before { - content: "\f44d"; } - -.fa-flushed:before { - content: "\f579"; } - -.fa-fly:before { - content: "\f417"; } - -.fa-folder:before { - content: "\f07b"; } - -.fa-folder-minus:before { - content: "\f65d"; } - -.fa-folder-open:before { - content: "\f07c"; } - -.fa-folder-plus:before { - content: "\f65e"; } - -.fa-font:before { - content: "\f031"; } - -.fa-font-awesome:before { - content: "\f2b4"; } - -.fa-font-awesome-alt:before { - content: "\f35c"; } - -.fa-font-awesome-flag:before { - content: "\f425"; } - -.fa-font-awesome-logo-full:before { - content: "\f4e6"; } - -.fa-fonticons:before { - content: "\f280"; } - -.fa-fonticons-fi:before { - content: "\f3a2"; } - -.fa-football-ball:before { - content: "\f44e"; } - -.fa-fort-awesome:before { - content: "\f286"; } - -.fa-fort-awesome-alt:before { - content: "\f3a3"; } - -.fa-forumbee:before { - content: "\f211"; } - -.fa-forward:before { - content: "\f04e"; } - -.fa-foursquare:before { - content: "\f180"; } - -.fa-free-code-camp:before { - content: "\f2c5"; } - -.fa-freebsd:before { - content: "\f3a4"; } - -.fa-frog:before { - content: "\f52e"; } - -.fa-frown:before { - content: "\f119"; } - -.fa-frown-open:before { - content: "\f57a"; } - -.fa-fulcrum:before { - content: "\f50b"; } - -.fa-funnel-dollar:before { - content: "\f662"; } - -.fa-futbol:before { - content: "\f1e3"; } - -.fa-galactic-republic:before { - content: "\f50c"; } - -.fa-galactic-senate:before { - content: "\f50d"; } - -.fa-gamepad:before { - content: "\f11b"; } - -.fa-gas-pump:before { - content: "\f52f"; } - -.fa-gavel:before { - content: "\f0e3"; } - -.fa-gem:before { - content: "\f3a5"; } - -.fa-genderless:before { - content: "\f22d"; } - -.fa-get-pocket:before { - content: "\f265"; } - -.fa-gg:before { - content: "\f260"; } - -.fa-gg-circle:before { - content: "\f261"; } - -.fa-ghost:before { - content: "\f6e2"; } - -.fa-gift:before { - content: "\f06b"; } - -.fa-gifts:before { - content: "\f79c"; } - -.fa-git:before { - content: "\f1d3"; } - -.fa-git-alt:before { - content: "\f841"; } - -.fa-git-square:before { - content: "\f1d2"; } - -.fa-github:before { - content: "\f09b"; } - -.fa-github-alt:before { - content: "\f113"; } - -.fa-github-square:before { - content: "\f092"; } - -.fa-gitkraken:before { - content: "\f3a6"; } - -.fa-gitlab:before { - content: "\f296"; } - -.fa-gitter:before { - content: "\f426"; } - -.fa-glass-cheers:before { - content: "\f79f"; } - -.fa-glass-martini:before { - content: "\f000"; } - -.fa-glass-martini-alt:before { - content: "\f57b"; } - -.fa-glass-whiskey:before { - content: "\f7a0"; } - -.fa-glasses:before { - content: "\f530"; } - -.fa-glide:before { - content: "\f2a5"; } - -.fa-glide-g:before { - content: "\f2a6"; } - -.fa-globe:before { - content: "\f0ac"; } - -.fa-globe-africa:before { - content: "\f57c"; } - -.fa-globe-americas:before { - content: "\f57d"; } - -.fa-globe-asia:before { - content: "\f57e"; } - -.fa-globe-europe:before { - content: "\f7a2"; } - -.fa-gofore:before { - content: "\f3a7"; } - -.fa-golf-ball:before { - content: "\f450"; } - -.fa-goodreads:before { - content: "\f3a8"; } - -.fa-goodreads-g:before { - content: "\f3a9"; } - -.fa-google:before { - content: "\f1a0"; } - -.fa-google-drive:before { - content: "\f3aa"; } - -.fa-google-play:before { - content: "\f3ab"; } - -.fa-google-plus:before { - content: "\f2b3"; } - -.fa-google-plus-g:before { - content: "\f0d5"; } - -.fa-google-plus-square:before { - content: "\f0d4"; } - -.fa-google-wallet:before { - content: "\f1ee"; } - -.fa-gopuram:before { - content: "\f664"; } - -.fa-graduation-cap:before { - content: "\f19d"; } - -.fa-gratipay:before { - content: "\f184"; } - -.fa-grav:before { - content: "\f2d6"; } - -.fa-greater-than:before { - content: "\f531"; } - -.fa-greater-than-equal:before { - content: "\f532"; } - -.fa-grimace:before { - content: "\f57f"; } - -.fa-grin:before { - content: "\f580"; } - -.fa-grin-alt:before { - content: "\f581"; } - -.fa-grin-beam:before { - content: "\f582"; } - -.fa-grin-beam-sweat:before { - content: "\f583"; } - -.fa-grin-hearts:before { - content: "\f584"; } - -.fa-grin-squint:before { - content: "\f585"; } - -.fa-grin-squint-tears:before { - content: "\f586"; } - -.fa-grin-stars:before { - content: "\f587"; } - -.fa-grin-tears:before { - content: "\f588"; } - -.fa-grin-tongue:before { - content: "\f589"; } - -.fa-grin-tongue-squint:before { - content: "\f58a"; } - -.fa-grin-tongue-wink:before { - content: "\f58b"; } - -.fa-grin-wink:before { - content: "\f58c"; } - -.fa-grip-horizontal:before { - content: "\f58d"; } - -.fa-grip-lines:before { - content: "\f7a4"; } - -.fa-grip-lines-vertical:before { - content: "\f7a5"; } - -.fa-grip-vertical:before { - content: "\f58e"; } - -.fa-gripfire:before { - content: "\f3ac"; } - -.fa-grunt:before { - content: "\f3ad"; } - -.fa-guitar:before { - content: "\f7a6"; } - -.fa-gulp:before { - content: "\f3ae"; } - -.fa-h-square:before { - content: "\f0fd"; } - -.fa-hacker-news:before { - content: "\f1d4"; } - -.fa-hacker-news-square:before { - content: "\f3af"; } - -.fa-hackerrank:before { - content: "\f5f7"; } - -.fa-hamburger:before { - content: "\f805"; } - -.fa-hammer:before { - content: "\f6e3"; } - -.fa-hamsa:before { - content: "\f665"; } - -.fa-hand-holding:before { - content: "\f4bd"; } - -.fa-hand-holding-heart:before { - content: "\f4be"; } - -.fa-hand-holding-medical:before { - content: "\f95c"; } - -.fa-hand-holding-usd:before { - content: "\f4c0"; } - -.fa-hand-holding-water:before { - content: "\f4c1"; } - -.fa-hand-lizard:before { - content: "\f258"; } - -.fa-hand-middle-finger:before { - content: "\f806"; } - -.fa-hand-paper:before { - content: "\f256"; } - -.fa-hand-peace:before { - content: "\f25b"; } - -.fa-hand-point-down:before { - content: "\f0a7"; } - -.fa-hand-point-left:before { - content: "\f0a5"; } - -.fa-hand-point-right:before { - content: "\f0a4"; } - -.fa-hand-point-up:before { - content: "\f0a6"; } - -.fa-hand-pointer:before { - content: "\f25a"; } - -.fa-hand-rock:before { - content: "\f255"; } - -.fa-hand-scissors:before { - content: "\f257"; } - -.fa-hand-sparkles:before { - content: "\f95d"; } - -.fa-hand-spock:before { - content: "\f259"; } - -.fa-hands:before { - content: "\f4c2"; } - -.fa-hands-helping:before { - content: "\f4c4"; } - -.fa-hands-wash:before { - content: "\f95e"; } - -.fa-handshake:before { - content: "\f2b5"; } - -.fa-handshake-alt-slash:before { - content: "\f95f"; } - -.fa-handshake-slash:before { - content: "\f960"; } - -.fa-hanukiah:before { - content: "\f6e6"; } - -.fa-hard-hat:before { - content: "\f807"; } - -.fa-hashtag:before { - content: "\f292"; } - -.fa-hat-cowboy:before { - content: "\f8c0"; } - -.fa-hat-cowboy-side:before { - content: "\f8c1"; } - -.fa-hat-wizard:before { - content: "\f6e8"; } - -.fa-hdd:before { - content: "\f0a0"; } - -.fa-head-side-cough:before { - content: "\f961"; } - -.fa-head-side-cough-slash:before { - content: "\f962"; } - -.fa-head-side-mask:before { - content: "\f963"; } - -.fa-head-side-virus:before { - content: "\f964"; } - -.fa-heading:before { - content: "\f1dc"; } - -.fa-headphones:before { - content: "\f025"; } - -.fa-headphones-alt:before { - content: "\f58f"; } - -.fa-headset:before { - content: "\f590"; } - -.fa-heart:before { - content: "\f004"; } - -.fa-heart-broken:before { - content: "\f7a9"; } - -.fa-heartbeat:before { - content: "\f21e"; } - -.fa-helicopter:before { - content: "\f533"; } - -.fa-highlighter:before { - content: "\f591"; } - -.fa-hiking:before { - content: "\f6ec"; } - -.fa-hippo:before { - content: "\f6ed"; } - -.fa-hips:before { - content: "\f452"; } - -.fa-hire-a-helper:before { - content: "\f3b0"; } - -.fa-history:before { - content: "\f1da"; } - -.fa-hockey-puck:before { - content: "\f453"; } - -.fa-holly-berry:before { - content: "\f7aa"; } - -.fa-home:before { - content: "\f015"; } - -.fa-hooli:before { - content: "\f427"; } - -.fa-hornbill:before { - content: "\f592"; } - -.fa-horse:before { - content: "\f6f0"; } - -.fa-horse-head:before { - content: "\f7ab"; } - -.fa-hospital:before { - content: "\f0f8"; } - -.fa-hospital-alt:before { - content: "\f47d"; } - -.fa-hospital-symbol:before { - content: "\f47e"; } - -.fa-hospital-user:before { - content: "\f80d"; } - -.fa-hot-tub:before { - content: "\f593"; } - -.fa-hotdog:before { - content: "\f80f"; } - -.fa-hotel:before { - content: "\f594"; } - -.fa-hotjar:before { - content: "\f3b1"; } - -.fa-hourglass:before { - content: "\f254"; } - -.fa-hourglass-end:before { - content: "\f253"; } - -.fa-hourglass-half:before { - content: "\f252"; } - -.fa-hourglass-start:before { - content: "\f251"; } - -.fa-house-damage:before { - content: "\f6f1"; } - -.fa-house-user:before { - content: "\f965"; } - -.fa-houzz:before { - content: "\f27c"; } - -.fa-hryvnia:before { - content: "\f6f2"; } - -.fa-html5:before { - content: "\f13b"; } - -.fa-hubspot:before { - content: "\f3b2"; } - -.fa-i-cursor:before { - content: "\f246"; } - -.fa-ice-cream:before { - content: "\f810"; } - -.fa-icicles:before { - content: "\f7ad"; } - -.fa-icons:before { - content: "\f86d"; } - -.fa-id-badge:before { - content: "\f2c1"; } - -.fa-id-card:before { - content: "\f2c2"; } - -.fa-id-card-alt:before { - content: "\f47f"; } - -.fa-ideal:before { - content: "\f913"; } - -.fa-igloo:before { - content: "\f7ae"; } - -.fa-image:before { - content: "\f03e"; } - -.fa-images:before { - content: "\f302"; } - -.fa-imdb:before { - content: "\f2d8"; } - -.fa-inbox:before { - content: "\f01c"; } - -.fa-indent:before { - content: "\f03c"; } - -.fa-industry:before { - content: "\f275"; } - -.fa-infinity:before { - content: "\f534"; } - -.fa-info:before { - content: "\f129"; } - -.fa-info-circle:before { - content: "\f05a"; } - -.fa-instagram:before { - content: "\f16d"; } - -.fa-instagram-square:before { - content: "\f955"; } - -.fa-intercom:before { - content: "\f7af"; } - -.fa-internet-explorer:before { - content: "\f26b"; } - -.fa-invision:before { - content: "\f7b0"; } - -.fa-ioxhost:before { - content: "\f208"; } - -.fa-italic:before { - content: "\f033"; } - -.fa-itch-io:before { - content: "\f83a"; } - -.fa-itunes:before { - content: "\f3b4"; } - -.fa-itunes-note:before { - content: "\f3b5"; } - -.fa-java:before { - content: "\f4e4"; } - -.fa-jedi:before { - content: "\f669"; } - -.fa-jedi-order:before { - content: "\f50e"; } - -.fa-jenkins:before { - content: "\f3b6"; } - -.fa-jira:before { - content: "\f7b1"; } - -.fa-joget:before { - content: "\f3b7"; } - -.fa-joint:before { - content: "\f595"; } - -.fa-joomla:before { - content: "\f1aa"; } - -.fa-journal-whills:before { - content: "\f66a"; } - -.fa-js:before { - content: "\f3b8"; } - -.fa-js-square:before { - content: "\f3b9"; } - -.fa-jsfiddle:before { - content: "\f1cc"; } - -.fa-kaaba:before { - content: "\f66b"; } - -.fa-kaggle:before { - content: "\f5fa"; } - -.fa-key:before { - content: "\f084"; } - -.fa-keybase:before { - content: "\f4f5"; } - -.fa-keyboard:before { - content: "\f11c"; } - -.fa-keycdn:before { - content: "\f3ba"; } - -.fa-khanda:before { - content: "\f66d"; } - -.fa-kickstarter:before { - content: "\f3bb"; } - -.fa-kickstarter-k:before { - content: "\f3bc"; } - -.fa-kiss:before { - content: "\f596"; } - -.fa-kiss-beam:before { - content: "\f597"; } - -.fa-kiss-wink-heart:before { - content: "\f598"; } - -.fa-kiwi-bird:before { - content: "\f535"; } - -.fa-korvue:before { - content: "\f42f"; } - -.fa-landmark:before { - content: "\f66f"; } - -.fa-language:before { - content: "\f1ab"; } - -.fa-laptop:before { - content: "\f109"; } - -.fa-laptop-code:before { - content: "\f5fc"; } - -.fa-laptop-house:before { - content: "\f966"; } - -.fa-laptop-medical:before { - content: "\f812"; } - -.fa-laravel:before { - content: "\f3bd"; } - -.fa-lastfm:before { - content: "\f202"; } - -.fa-lastfm-square:before { - content: "\f203"; } - -.fa-laugh:before { - content: "\f599"; } - -.fa-laugh-beam:before { - content: "\f59a"; } - -.fa-laugh-squint:before { - content: "\f59b"; } - -.fa-laugh-wink:before { - content: "\f59c"; } - -.fa-layer-group:before { - content: "\f5fd"; } - -.fa-leaf:before { - content: "\f06c"; } - -.fa-leanpub:before { - content: "\f212"; } - -.fa-lemon:before { - content: "\f094"; } - -.fa-less:before { - content: "\f41d"; } - -.fa-less-than:before { - content: "\f536"; } - -.fa-less-than-equal:before { - content: "\f537"; } - -.fa-level-down-alt:before { - content: "\f3be"; } - -.fa-level-up-alt:before { - content: "\f3bf"; } - -.fa-life-ring:before { - content: "\f1cd"; } - -.fa-lightbulb:before { - content: "\f0eb"; } - -.fa-line:before { - content: "\f3c0"; } - -.fa-link:before { - content: "\f0c1"; } - -.fa-linkedin:before { - content: "\f08c"; } - -.fa-linkedin-in:before { - content: "\f0e1"; } - -.fa-linode:before { - content: "\f2b8"; } - -.fa-linux:before { - content: "\f17c"; } - -.fa-lira-sign:before { - content: "\f195"; } - -.fa-list:before { - content: "\f03a"; } - -.fa-list-alt:before { - content: "\f022"; } - -.fa-list-ol:before { - content: "\f0cb"; } - -.fa-list-ul:before { - content: "\f0ca"; } - -.fa-location-arrow:before { - content: "\f124"; } - -.fa-lock:before { - content: "\f023"; } - -.fa-lock-open:before { - content: "\f3c1"; } - -.fa-long-arrow-alt-down:before { - content: "\f309"; } - -.fa-long-arrow-alt-left:before { - content: "\f30a"; } - -.fa-long-arrow-alt-right:before { - content: "\f30b"; } - -.fa-long-arrow-alt-up:before { - content: "\f30c"; } - -.fa-low-vision:before { - content: "\f2a8"; } - -.fa-luggage-cart:before { - content: "\f59d"; } - -.fa-lungs:before { - content: "\f604"; } - -.fa-lungs-virus:before { - content: "\f967"; } - -.fa-lyft:before { - content: "\f3c3"; } - -.fa-magento:before { - content: "\f3c4"; } - -.fa-magic:before { - content: "\f0d0"; } - -.fa-magnet:before { - content: "\f076"; } - -.fa-mail-bulk:before { - content: "\f674"; } - -.fa-mailchimp:before { - content: "\f59e"; } - -.fa-male:before { - content: "\f183"; } - -.fa-mandalorian:before { - content: "\f50f"; } - -.fa-map:before { - content: "\f279"; } - -.fa-map-marked:before { - content: "\f59f"; } - -.fa-map-marked-alt:before { - content: "\f5a0"; } - -.fa-map-marker:before { - content: "\f041"; } - -.fa-map-marker-alt:before { - content: "\f3c5"; } - -.fa-map-pin:before { - content: "\f276"; } - -.fa-map-signs:before { - content: "\f277"; } - -.fa-markdown:before { - content: "\f60f"; } - -.fa-marker:before { - content: "\f5a1"; } - -.fa-mars:before { - content: "\f222"; } - -.fa-mars-double:before { - content: "\f227"; } - -.fa-mars-stroke:before { - content: "\f229"; } - -.fa-mars-stroke-h:before { - content: "\f22b"; } - -.fa-mars-stroke-v:before { - content: "\f22a"; } - -.fa-mask:before { - content: "\f6fa"; } - -.fa-mastodon:before { - content: "\f4f6"; } - -.fa-maxcdn:before { - content: "\f136"; } - -.fa-mdb:before { - content: "\f8ca"; } - -.fa-medal:before { - content: "\f5a2"; } - -.fa-medapps:before { - content: "\f3c6"; } - -.fa-medium:before { - content: "\f23a"; } - -.fa-medium-m:before { - content: "\f3c7"; } - -.fa-medkit:before { - content: "\f0fa"; } - -.fa-medrt:before { - content: "\f3c8"; } - -.fa-meetup:before { - content: "\f2e0"; } - -.fa-megaport:before { - content: "\f5a3"; } - -.fa-meh:before { - content: "\f11a"; } - -.fa-meh-blank:before { - content: "\f5a4"; } - -.fa-meh-rolling-eyes:before { - content: "\f5a5"; } - -.fa-memory:before { - content: "\f538"; } - -.fa-mendeley:before { - content: "\f7b3"; } - -.fa-menorah:before { - content: "\f676"; } - -.fa-mercury:before { - content: "\f223"; } - -.fa-meteor:before { - content: "\f753"; } - -.fa-microblog:before { - content: "\f91a"; } - -.fa-microchip:before { - content: "\f2db"; } - -.fa-microphone:before { - content: "\f130"; } - -.fa-microphone-alt:before { - content: "\f3c9"; } - -.fa-microphone-alt-slash:before { - content: "\f539"; } - -.fa-microphone-slash:before { - content: "\f131"; } - -.fa-microscope:before { - content: "\f610"; } - -.fa-microsoft:before { - content: "\f3ca"; } - -.fa-minus:before { - content: "\f068"; } - -.fa-minus-circle:before { - content: "\f056"; } - -.fa-minus-square:before { - content: "\f146"; } - -.fa-mitten:before { - content: "\f7b5"; } - -.fa-mix:before { - content: "\f3cb"; } - -.fa-mixcloud:before { - content: "\f289"; } - -.fa-mixer:before { - content: "\f956"; } - -.fa-mizuni:before { - content: "\f3cc"; } - -.fa-mobile:before { - content: "\f10b"; } - -.fa-mobile-alt:before { - content: "\f3cd"; } - -.fa-modx:before { - content: "\f285"; } - -.fa-monero:before { - content: "\f3d0"; } - -.fa-money-bill:before { - content: "\f0d6"; } - -.fa-money-bill-alt:before { - content: "\f3d1"; } - -.fa-money-bill-wave:before { - content: "\f53a"; } - -.fa-money-bill-wave-alt:before { - content: "\f53b"; } - -.fa-money-check:before { - content: "\f53c"; } - -.fa-money-check-alt:before { - content: "\f53d"; } - -.fa-monument:before { - content: "\f5a6"; } - -.fa-moon:before { - content: "\f186"; } - -.fa-mortar-pestle:before { - content: "\f5a7"; } - -.fa-mosque:before { - content: "\f678"; } - -.fa-motorcycle:before { - content: "\f21c"; } - -.fa-mountain:before { - content: "\f6fc"; } - -.fa-mouse:before { - content: "\f8cc"; } - -.fa-mouse-pointer:before { - content: "\f245"; } - -.fa-mug-hot:before { - content: "\f7b6"; } - -.fa-music:before { - content: "\f001"; } - -.fa-napster:before { - content: "\f3d2"; } - -.fa-neos:before { - content: "\f612"; } - -.fa-network-wired:before { - content: "\f6ff"; } - -.fa-neuter:before { - content: "\f22c"; } - -.fa-newspaper:before { - content: "\f1ea"; } - -.fa-nimblr:before { - content: "\f5a8"; } - -.fa-node:before { - content: "\f419"; } - -.fa-node-js:before { - content: "\f3d3"; } - -.fa-not-equal:before { - content: "\f53e"; } - -.fa-notes-medical:before { - content: "\f481"; } - -.fa-npm:before { - content: "\f3d4"; } - -.fa-ns8:before { - content: "\f3d5"; } - -.fa-nutritionix:before { - content: "\f3d6"; } - -.fa-object-group:before { - content: "\f247"; } - -.fa-object-ungroup:before { - content: "\f248"; } - -.fa-odnoklassniki:before { - content: "\f263"; } - -.fa-odnoklassniki-square:before { - content: "\f264"; } - -.fa-oil-can:before { - content: "\f613"; } - -.fa-old-republic:before { - content: "\f510"; } - -.fa-om:before { - content: "\f679"; } - -.fa-opencart:before { - content: "\f23d"; } - -.fa-openid:before { - content: "\f19b"; } - -.fa-opera:before { - content: "\f26a"; } - -.fa-optin-monster:before { - content: "\f23c"; } - -.fa-orcid:before { - content: "\f8d2"; } - -.fa-osi:before { - content: "\f41a"; } - -.fa-otter:before { - content: "\f700"; } - -.fa-outdent:before { - content: "\f03b"; } - -.fa-page4:before { - content: "\f3d7"; } - -.fa-pagelines:before { - content: "\f18c"; } - -.fa-pager:before { - content: "\f815"; } - -.fa-paint-brush:before { - content: "\f1fc"; } - -.fa-paint-roller:before { - content: "\f5aa"; } - -.fa-palette:before { - content: "\f53f"; } - -.fa-palfed:before { - content: "\f3d8"; } - -.fa-pallet:before { - content: "\f482"; } - -.fa-paper-plane:before { - content: "\f1d8"; } - -.fa-paperclip:before { - content: "\f0c6"; } - -.fa-parachute-box:before { - content: "\f4cd"; } - -.fa-paragraph:before { - content: "\f1dd"; } - -.fa-parking:before { - content: "\f540"; } - -.fa-passport:before { - content: "\f5ab"; } - -.fa-pastafarianism:before { - content: "\f67b"; } - -.fa-paste:before { - content: "\f0ea"; } - -.fa-patreon:before { - content: "\f3d9"; } - -.fa-pause:before { - content: "\f04c"; } - -.fa-pause-circle:before { - content: "\f28b"; } - -.fa-paw:before { - content: "\f1b0"; } - -.fa-paypal:before { - content: "\f1ed"; } - -.fa-peace:before { - content: "\f67c"; } - -.fa-pen:before { - content: "\f304"; } - -.fa-pen-alt:before { - content: "\f305"; } - -.fa-pen-fancy:before { - content: "\f5ac"; } - -.fa-pen-nib:before { - content: "\f5ad"; } - -.fa-pen-square:before { - content: "\f14b"; } - -.fa-pencil-alt:before { - content: "\f303"; } - -.fa-pencil-ruler:before { - content: "\f5ae"; } - -.fa-penny-arcade:before { - content: "\f704"; } - -.fa-people-arrows:before { - content: "\f968"; } - -.fa-people-carry:before { - content: "\f4ce"; } - -.fa-pepper-hot:before { - content: "\f816"; } - -.fa-percent:before { - content: "\f295"; } - -.fa-percentage:before { - content: "\f541"; } - -.fa-periscope:before { - content: "\f3da"; } - -.fa-person-booth:before { - content: "\f756"; } - -.fa-phabricator:before { - content: "\f3db"; } - -.fa-phoenix-framework:before { - content: "\f3dc"; } - -.fa-phoenix-squadron:before { - content: "\f511"; } - -.fa-phone:before { - content: "\f095"; } - -.fa-phone-alt:before { - content: "\f879"; } - -.fa-phone-slash:before { - content: "\f3dd"; } - -.fa-phone-square:before { - content: "\f098"; } - -.fa-phone-square-alt:before { - content: "\f87b"; } - -.fa-phone-volume:before { - content: "\f2a0"; } - -.fa-photo-video:before { - content: "\f87c"; } - -.fa-php:before { - content: "\f457"; } - -.fa-pied-piper:before { - content: "\f2ae"; } - -.fa-pied-piper-alt:before { - content: "\f1a8"; } - -.fa-pied-piper-hat:before { - content: "\f4e5"; } - -.fa-pied-piper-pp:before { - content: "\f1a7"; } - -.fa-pied-piper-square:before { - content: "\f91e"; } - -.fa-piggy-bank:before { - content: "\f4d3"; } - -.fa-pills:before { - content: "\f484"; } - -.fa-pinterest:before { - content: "\f0d2"; } - -.fa-pinterest-p:before { - content: "\f231"; } - -.fa-pinterest-square:before { - content: "\f0d3"; } - -.fa-pizza-slice:before { - content: "\f818"; } - -.fa-place-of-worship:before { - content: "\f67f"; } - -.fa-plane:before { - content: "\f072"; } - -.fa-plane-arrival:before { - content: "\f5af"; } - -.fa-plane-departure:before { - content: "\f5b0"; } - -.fa-plane-slash:before { - content: "\f969"; } - -.fa-play:before { - content: "\f04b"; } - -.fa-play-circle:before { - content: "\f144"; } - -.fa-playstation:before { - content: "\f3df"; } - -.fa-plug:before { - content: "\f1e6"; } - -.fa-plus:before { - content: "\f067"; } - -.fa-plus-circle:before { - content: "\f055"; } - -.fa-plus-square:before { - content: "\f0fe"; } - -.fa-podcast:before { - content: "\f2ce"; } - -.fa-poll:before { - content: "\f681"; } - -.fa-poll-h:before { - content: "\f682"; } - -.fa-poo:before { - content: "\f2fe"; } - -.fa-poo-storm:before { - content: "\f75a"; } - -.fa-poop:before { - content: "\f619"; } - -.fa-portrait:before { - content: "\f3e0"; } - -.fa-pound-sign:before { - content: "\f154"; } - -.fa-power-off:before { - content: "\f011"; } - -.fa-pray:before { - content: "\f683"; } - -.fa-praying-hands:before { - content: "\f684"; } - -.fa-prescription:before { - content: "\f5b1"; } - -.fa-prescription-bottle:before { - content: "\f485"; } - -.fa-prescription-bottle-alt:before { - content: "\f486"; } - -.fa-print:before { - content: "\f02f"; } - -.fa-procedures:before { - content: "\f487"; } - -.fa-product-hunt:before { - content: "\f288"; } - -.fa-project-diagram:before { - content: "\f542"; } - -.fa-pump-medical:before { - content: "\f96a"; } - -.fa-pump-soap:before { - content: "\f96b"; } - -.fa-pushed:before { - content: "\f3e1"; } - -.fa-puzzle-piece:before { - content: "\f12e"; } - -.fa-python:before { - content: "\f3e2"; } - -.fa-qq:before { - content: "\f1d6"; } - -.fa-qrcode:before { - content: "\f029"; } - -.fa-question:before { - content: "\f128"; } - -.fa-question-circle:before { - content: "\f059"; } - -.fa-quidditch:before { - content: "\f458"; } - -.fa-quinscape:before { - content: "\f459"; } - -.fa-quora:before { - content: "\f2c4"; } - -.fa-quote-left:before { - content: "\f10d"; } - -.fa-quote-right:before { - content: "\f10e"; } - -.fa-quran:before { - content: "\f687"; } - -.fa-r-project:before { - content: "\f4f7"; } - -.fa-radiation:before { - content: "\f7b9"; } - -.fa-radiation-alt:before { - content: "\f7ba"; } - -.fa-rainbow:before { - content: "\f75b"; } - -.fa-random:before { - content: "\f074"; } - -.fa-raspberry-pi:before { - content: "\f7bb"; } - -.fa-ravelry:before { - content: "\f2d9"; } - -.fa-react:before { - content: "\f41b"; } - -.fa-reacteurope:before { - content: "\f75d"; } - -.fa-readme:before { - content: "\f4d5"; } - -.fa-rebel:before { - content: "\f1d0"; } - -.fa-receipt:before { - content: "\f543"; } - -.fa-record-vinyl:before { - content: "\f8d9"; } - -.fa-recycle:before { - content: "\f1b8"; } - -.fa-red-river:before { - content: "\f3e3"; } - -.fa-reddit:before { - content: "\f1a1"; } - -.fa-reddit-alien:before { - content: "\f281"; } - -.fa-reddit-square:before { - content: "\f1a2"; } - -.fa-redhat:before { - content: "\f7bc"; } - -.fa-redo:before { - content: "\f01e"; } - -.fa-redo-alt:before { - content: "\f2f9"; } - -.fa-registered:before { - content: "\f25d"; } - -.fa-remove-format:before { - content: "\f87d"; } - -.fa-renren:before { - content: "\f18b"; } - -.fa-reply:before { - content: "\f3e5"; } - -.fa-reply-all:before { - content: "\f122"; } - -.fa-replyd:before { - content: "\f3e6"; } - -.fa-republican:before { - content: "\f75e"; } - -.fa-researchgate:before { - content: "\f4f8"; } - -.fa-resolving:before { - content: "\f3e7"; } - -.fa-restroom:before { - content: "\f7bd"; } - -.fa-retweet:before { - content: "\f079"; } - -.fa-rev:before { - content: "\f5b2"; } - -.fa-ribbon:before { - content: "\f4d6"; } - -.fa-ring:before { - content: "\f70b"; } - -.fa-road:before { - content: "\f018"; } - -.fa-robot:before { - content: "\f544"; } - -.fa-rocket:before { - content: "\f135"; } - -.fa-rocketchat:before { - content: "\f3e8"; } - -.fa-rockrms:before { - content: "\f3e9"; } - -.fa-route:before { - content: "\f4d7"; } - -.fa-rss:before { - content: "\f09e"; } - -.fa-rss-square:before { - content: "\f143"; } - -.fa-ruble-sign:before { - content: "\f158"; } - -.fa-ruler:before { - content: "\f545"; } - -.fa-ruler-combined:before { - content: "\f546"; } - -.fa-ruler-horizontal:before { - content: "\f547"; } - -.fa-ruler-vertical:before { - content: "\f548"; } - -.fa-running:before { - content: "\f70c"; } - -.fa-rupee-sign:before { - content: "\f156"; } - -.fa-sad-cry:before { - content: "\f5b3"; } - -.fa-sad-tear:before { - content: "\f5b4"; } - -.fa-safari:before { - content: "\f267"; } - -.fa-salesforce:before { - content: "\f83b"; } - -.fa-sass:before { - content: "\f41e"; } - -.fa-satellite:before { - content: "\f7bf"; } - -.fa-satellite-dish:before { - content: "\f7c0"; } - -.fa-save:before { - content: "\f0c7"; } - -.fa-schlix:before { - content: "\f3ea"; } - -.fa-school:before { - content: "\f549"; } - -.fa-screwdriver:before { - content: "\f54a"; } - -.fa-scribd:before { - content: "\f28a"; } - -.fa-scroll:before { - content: "\f70e"; } - -.fa-sd-card:before { - content: "\f7c2"; } - -.fa-search:before { - content: "\f002"; } - -.fa-search-dollar:before { - content: "\f688"; } - -.fa-search-location:before { - content: "\f689"; } - -.fa-search-minus:before { - content: "\f010"; } - -.fa-search-plus:before { - content: "\f00e"; } - -.fa-searchengin:before { - content: "\f3eb"; } - -.fa-seedling:before { - content: "\f4d8"; } - -.fa-sellcast:before { - content: "\f2da"; } - -.fa-sellsy:before { - content: "\f213"; } - -.fa-server:before { - content: "\f233"; } - -.fa-servicestack:before { - content: "\f3ec"; } - -.fa-shapes:before { - content: "\f61f"; } - -.fa-share:before { - content: "\f064"; } - -.fa-share-alt:before { - content: "\f1e0"; } - -.fa-share-alt-square:before { - content: "\f1e1"; } - -.fa-share-square:before { - content: "\f14d"; } - -.fa-shekel-sign:before { - content: "\f20b"; } - -.fa-shield-alt:before { - content: "\f3ed"; } - -.fa-shield-virus:before { - content: "\f96c"; } - -.fa-ship:before { - content: "\f21a"; } - -.fa-shipping-fast:before { - content: "\f48b"; } - -.fa-shirtsinbulk:before { - content: "\f214"; } - -.fa-shoe-prints:before { - content: "\f54b"; } - -.fa-shopify:before { - content: "\f957"; } - -.fa-shopping-bag:before { - content: "\f290"; } - -.fa-shopping-basket:before { - content: "\f291"; } - -.fa-shopping-cart:before { - content: "\f07a"; } - -.fa-shopware:before { - content: "\f5b5"; } - -.fa-shower:before { - content: "\f2cc"; } - -.fa-shuttle-van:before { - content: "\f5b6"; } - -.fa-sign:before { - content: "\f4d9"; } - -.fa-sign-in-alt:before { - content: "\f2f6"; } - -.fa-sign-language:before { - content: "\f2a7"; } - -.fa-sign-out-alt:before { - content: "\f2f5"; } - -.fa-signal:before { - content: "\f012"; } - -.fa-signature:before { - content: "\f5b7"; } - -.fa-sim-card:before { - content: "\f7c4"; } - -.fa-simplybuilt:before { - content: "\f215"; } - -.fa-sistrix:before { - content: "\f3ee"; } - -.fa-sitemap:before { - content: "\f0e8"; } - -.fa-sith:before { - content: "\f512"; } - -.fa-skating:before { - content: "\f7c5"; } - -.fa-sketch:before { - content: "\f7c6"; } - -.fa-skiing:before { - content: "\f7c9"; } - -.fa-skiing-nordic:before { - content: "\f7ca"; } - -.fa-skull:before { - content: "\f54c"; } - -.fa-skull-crossbones:before { - content: "\f714"; } - -.fa-skyatlas:before { - content: "\f216"; } - -.fa-skype:before { - content: "\f17e"; } - -.fa-slack:before { - content: "\f198"; } - -.fa-slack-hash:before { - content: "\f3ef"; } - -.fa-slash:before { - content: "\f715"; } - -.fa-sleigh:before { - content: "\f7cc"; } - -.fa-sliders-h:before { - content: "\f1de"; } - -.fa-slideshare:before { - content: "\f1e7"; } - -.fa-smile:before { - content: "\f118"; } - -.fa-smile-beam:before { - content: "\f5b8"; } - -.fa-smile-wink:before { - content: "\f4da"; } - -.fa-smog:before { - content: "\f75f"; } - -.fa-smoking:before { - content: "\f48d"; } - -.fa-smoking-ban:before { - content: "\f54d"; } - -.fa-sms:before { - content: "\f7cd"; } - -.fa-snapchat:before { - content: "\f2ab"; } - -.fa-snapchat-ghost:before { - content: "\f2ac"; } - -.fa-snapchat-square:before { - content: "\f2ad"; } - -.fa-snowboarding:before { - content: "\f7ce"; } - -.fa-snowflake:before { - content: "\f2dc"; } - -.fa-snowman:before { - content: "\f7d0"; } - -.fa-snowplow:before { - content: "\f7d2"; } - -.fa-soap:before { - content: "\f96e"; } - -.fa-socks:before { - content: "\f696"; } - -.fa-solar-panel:before { - content: "\f5ba"; } - -.fa-sort:before { - content: "\f0dc"; } - -.fa-sort-alpha-down:before { - content: "\f15d"; } - -.fa-sort-alpha-down-alt:before { - content: "\f881"; } - -.fa-sort-alpha-up:before { - content: "\f15e"; } - -.fa-sort-alpha-up-alt:before { - content: "\f882"; } - -.fa-sort-amount-down:before { - content: "\f160"; } - -.fa-sort-amount-down-alt:before { - content: "\f884"; } - -.fa-sort-amount-up:before { - content: "\f161"; } - -.fa-sort-amount-up-alt:before { - content: "\f885"; } - -.fa-sort-down:before { - content: "\f0dd"; } - -.fa-sort-numeric-down:before { - content: "\f162"; } - -.fa-sort-numeric-down-alt:before { - content: "\f886"; } - -.fa-sort-numeric-up:before { - content: "\f163"; } - -.fa-sort-numeric-up-alt:before { - content: "\f887"; } - -.fa-sort-up:before { - content: "\f0de"; } - -.fa-soundcloud:before { - content: "\f1be"; } - -.fa-sourcetree:before { - content: "\f7d3"; } - -.fa-spa:before { - content: "\f5bb"; } - -.fa-space-shuttle:before { - content: "\f197"; } - -.fa-speakap:before { - content: "\f3f3"; } - -.fa-speaker-deck:before { - content: "\f83c"; } - -.fa-spell-check:before { - content: "\f891"; } - -.fa-spider:before { - content: "\f717"; } - -.fa-spinner:before { - content: "\f110"; } - -.fa-splotch:before { - content: "\f5bc"; } - -.fa-spotify:before { - content: "\f1bc"; } - -.fa-spray-can:before { - content: "\f5bd"; } - -.fa-square:before { - content: "\f0c8"; } - -.fa-square-full:before { - content: "\f45c"; } - -.fa-square-root-alt:before { - content: "\f698"; } - -.fa-squarespace:before { - content: "\f5be"; } - -.fa-stack-exchange:before { - content: "\f18d"; } - -.fa-stack-overflow:before { - content: "\f16c"; } - -.fa-stackpath:before { - content: "\f842"; } - -.fa-stamp:before { - content: "\f5bf"; } - -.fa-star:before { - content: "\f005"; } - -.fa-star-and-crescent:before { - content: "\f699"; } - -.fa-star-half:before { - content: "\f089"; } - -.fa-star-half-alt:before { - content: "\f5c0"; } - -.fa-star-of-david:before { - content: "\f69a"; } - -.fa-star-of-life:before { - content: "\f621"; } - -.fa-staylinked:before { - content: "\f3f5"; } - -.fa-steam:before { - content: "\f1b6"; } - -.fa-steam-square:before { - content: "\f1b7"; } - -.fa-steam-symbol:before { - content: "\f3f6"; } - -.fa-step-backward:before { - content: "\f048"; } - -.fa-step-forward:before { - content: "\f051"; } - -.fa-stethoscope:before { - content: "\f0f1"; } - -.fa-sticker-mule:before { - content: "\f3f7"; } - -.fa-sticky-note:before { - content: "\f249"; } - -.fa-stop:before { - content: "\f04d"; } - -.fa-stop-circle:before { - content: "\f28d"; } - -.fa-stopwatch:before { - content: "\f2f2"; } - -.fa-stopwatch-20:before { - content: "\f96f"; } - -.fa-store:before { - content: "\f54e"; } - -.fa-store-alt:before { - content: "\f54f"; } - -.fa-store-alt-slash:before { - content: "\f970"; } - -.fa-store-slash:before { - content: "\f971"; } - -.fa-strava:before { - content: "\f428"; } - -.fa-stream:before { - content: "\f550"; } - -.fa-street-view:before { - content: "\f21d"; } - -.fa-strikethrough:before { - content: "\f0cc"; } - -.fa-stripe:before { - content: "\f429"; } - -.fa-stripe-s:before { - content: "\f42a"; } - -.fa-stroopwafel:before { - content: "\f551"; } - -.fa-studiovinari:before { - content: "\f3f8"; } - -.fa-stumbleupon:before { - content: "\f1a4"; } - -.fa-stumbleupon-circle:before { - content: "\f1a3"; } - -.fa-subscript:before { - content: "\f12c"; } - -.fa-subway:before { - content: "\f239"; } - -.fa-suitcase:before { - content: "\f0f2"; } - -.fa-suitcase-rolling:before { - content: "\f5c1"; } - -.fa-sun:before { - content: "\f185"; } - -.fa-superpowers:before { - content: "\f2dd"; } - -.fa-superscript:before { - content: "\f12b"; } - -.fa-supple:before { - content: "\f3f9"; } - -.fa-surprise:before { - content: "\f5c2"; } - -.fa-suse:before { - content: "\f7d6"; } - -.fa-swatchbook:before { - content: "\f5c3"; } - -.fa-swift:before { - content: "\f8e1"; } - -.fa-swimmer:before { - content: "\f5c4"; } - -.fa-swimming-pool:before { - content: "\f5c5"; } - -.fa-symfony:before { - content: "\f83d"; } - -.fa-synagogue:before { - content: "\f69b"; } - -.fa-sync:before { - content: "\f021"; } - -.fa-sync-alt:before { - content: "\f2f1"; } - -.fa-syringe:before { - content: "\f48e"; } - -.fa-table:before { - content: "\f0ce"; } - -.fa-table-tennis:before { - content: "\f45d"; } - -.fa-tablet:before { - content: "\f10a"; } - -.fa-tablet-alt:before { - content: "\f3fa"; } - -.fa-tablets:before { - content: "\f490"; } - -.fa-tachometer-alt:before { - content: "\f3fd"; } - -.fa-tag:before { - content: "\f02b"; } - -.fa-tags:before { - content: "\f02c"; } - -.fa-tape:before { - content: "\f4db"; } - -.fa-tasks:before { - content: "\f0ae"; } - -.fa-taxi:before { - content: "\f1ba"; } - -.fa-teamspeak:before { - content: "\f4f9"; } - -.fa-teeth:before { - content: "\f62e"; } - -.fa-teeth-open:before { - content: "\f62f"; } - -.fa-telegram:before { - content: "\f2c6"; } - -.fa-telegram-plane:before { - content: "\f3fe"; } - -.fa-temperature-high:before { - content: "\f769"; } - -.fa-temperature-low:before { - content: "\f76b"; } - -.fa-tencent-weibo:before { - content: "\f1d5"; } - -.fa-tenge:before { - content: "\f7d7"; } - -.fa-terminal:before { - content: "\f120"; } - -.fa-text-height:before { - content: "\f034"; } - -.fa-text-width:before { - content: "\f035"; } - -.fa-th:before { - content: "\f00a"; } - -.fa-th-large:before { - content: "\f009"; } - -.fa-th-list:before { - content: "\f00b"; } - -.fa-the-red-yeti:before { - content: "\f69d"; } - -.fa-theater-masks:before { - content: "\f630"; } - -.fa-themeco:before { - content: "\f5c6"; } - -.fa-themeisle:before { - content: "\f2b2"; } - -.fa-thermometer:before { - content: "\f491"; } - -.fa-thermometer-empty:before { - content: "\f2cb"; } - -.fa-thermometer-full:before { - content: "\f2c7"; } - -.fa-thermometer-half:before { - content: "\f2c9"; } - -.fa-thermometer-quarter:before { - content: "\f2ca"; } - -.fa-thermometer-three-quarters:before { - content: "\f2c8"; } - -.fa-think-peaks:before { - content: "\f731"; } - -.fa-thumbs-down:before { - content: "\f165"; } - -.fa-thumbs-up:before { - content: "\f164"; } - -.fa-thumbtack:before { - content: "\f08d"; } - -.fa-ticket-alt:before { - content: "\f3ff"; } - -.fa-times:before { - content: "\f00d"; } - -.fa-times-circle:before { - content: "\f057"; } - -.fa-tint:before { - content: "\f043"; } - -.fa-tint-slash:before { - content: "\f5c7"; } - -.fa-tired:before { - content: "\f5c8"; } - -.fa-toggle-off:before { - content: "\f204"; } - -.fa-toggle-on:before { - content: "\f205"; } - -.fa-toilet:before { - content: "\f7d8"; } - -.fa-toilet-paper:before { - content: "\f71e"; } - -.fa-toilet-paper-slash:before { - content: "\f972"; } - -.fa-toolbox:before { - content: "\f552"; } - -.fa-tools:before { - content: "\f7d9"; } - -.fa-tooth:before { - content: "\f5c9"; } - -.fa-torah:before { - content: "\f6a0"; } - -.fa-torii-gate:before { - content: "\f6a1"; } - -.fa-tractor:before { - content: "\f722"; } - -.fa-trade-federation:before { - content: "\f513"; } - -.fa-trademark:before { - content: "\f25c"; } - -.fa-traffic-light:before { - content: "\f637"; } - -.fa-trailer:before { - content: "\f941"; } - -.fa-train:before { - content: "\f238"; } - -.fa-tram:before { - content: "\f7da"; } - -.fa-transgender:before { - content: "\f224"; } - -.fa-transgender-alt:before { - content: "\f225"; } - -.fa-trash:before { - content: "\f1f8"; } - -.fa-trash-alt:before { - content: "\f2ed"; } - -.fa-trash-restore:before { - content: "\f829"; } - -.fa-trash-restore-alt:before { - content: "\f82a"; } - -.fa-tree:before { - content: "\f1bb"; } - -.fa-trello:before { - content: "\f181"; } - -.fa-tripadvisor:before { - content: "\f262"; } - -.fa-trophy:before { - content: "\f091"; } - -.fa-truck:before { - content: "\f0d1"; } - -.fa-truck-loading:before { - content: "\f4de"; } - -.fa-truck-monster:before { - content: "\f63b"; } - -.fa-truck-moving:before { - content: "\f4df"; } - -.fa-truck-pickup:before { - content: "\f63c"; } - -.fa-tshirt:before { - content: "\f553"; } - -.fa-tty:before { - content: "\f1e4"; } - -.fa-tumblr:before { - content: "\f173"; } - -.fa-tumblr-square:before { - content: "\f174"; } - -.fa-tv:before { - content: "\f26c"; } - -.fa-twitch:before { - content: "\f1e8"; } - -.fa-twitter:before { - content: "\f099"; } - -.fa-twitter-square:before { - content: "\f081"; } - -.fa-typo3:before { - content: "\f42b"; } - -.fa-uber:before { - content: "\f402"; } - -.fa-ubuntu:before { - content: "\f7df"; } - -.fa-uikit:before { - content: "\f403"; } - -.fa-umbraco:before { - content: "\f8e8"; } - -.fa-umbrella:before { - content: "\f0e9"; } - -.fa-umbrella-beach:before { - content: "\f5ca"; } - -.fa-underline:before { - content: "\f0cd"; } - -.fa-undo:before { - content: "\f0e2"; } - -.fa-undo-alt:before { - content: "\f2ea"; } - -.fa-uniregistry:before { - content: "\f404"; } - -.fa-unity:before { - content: "\f949"; } - -.fa-universal-access:before { - content: "\f29a"; } - -.fa-university:before { - content: "\f19c"; } - -.fa-unlink:before { - content: "\f127"; } - -.fa-unlock:before { - content: "\f09c"; } - -.fa-unlock-alt:before { - content: "\f13e"; } - -.fa-untappd:before { - content: "\f405"; } - -.fa-upload:before { - content: "\f093"; } - -.fa-ups:before { - content: "\f7e0"; } - -.fa-usb:before { - content: "\f287"; } - -.fa-user:before { - content: "\f007"; } - -.fa-user-alt:before { - content: "\f406"; } - -.fa-user-alt-slash:before { - content: "\f4fa"; } - -.fa-user-astronaut:before { - content: "\f4fb"; } - -.fa-user-check:before { - content: "\f4fc"; } - -.fa-user-circle:before { - content: "\f2bd"; } - -.fa-user-clock:before { - content: "\f4fd"; } - -.fa-user-cog:before { - content: "\f4fe"; } - -.fa-user-edit:before { - content: "\f4ff"; } - -.fa-user-friends:before { - content: "\f500"; } - -.fa-user-graduate:before { - content: "\f501"; } - -.fa-user-injured:before { - content: "\f728"; } - -.fa-user-lock:before { - content: "\f502"; } - -.fa-user-md:before { - content: "\f0f0"; } - -.fa-user-minus:before { - content: "\f503"; } - -.fa-user-ninja:before { - content: "\f504"; } - -.fa-user-nurse:before { - content: "\f82f"; } - -.fa-user-plus:before { - content: "\f234"; } - -.fa-user-secret:before { - content: "\f21b"; } - -.fa-user-shield:before { - content: "\f505"; } - -.fa-user-slash:before { - content: "\f506"; } - -.fa-user-tag:before { - content: "\f507"; } - -.fa-user-tie:before { - content: "\f508"; } - -.fa-user-times:before { - content: "\f235"; } - -.fa-users:before { - content: "\f0c0"; } - -.fa-users-cog:before { - content: "\f509"; } - -.fa-usps:before { - content: "\f7e1"; } - -.fa-ussunnah:before { - content: "\f407"; } - -.fa-utensil-spoon:before { - content: "\f2e5"; } - -.fa-utensils:before { - content: "\f2e7"; } - -.fa-vaadin:before { - content: "\f408"; } - -.fa-vector-square:before { - content: "\f5cb"; } - -.fa-venus:before { - content: "\f221"; } - -.fa-venus-double:before { - content: "\f226"; } - -.fa-venus-mars:before { - content: "\f228"; } - -.fa-viacoin:before { - content: "\f237"; } - -.fa-viadeo:before { - content: "\f2a9"; } - -.fa-viadeo-square:before { - content: "\f2aa"; } - -.fa-vial:before { - content: "\f492"; } - -.fa-vials:before { - content: "\f493"; } - -.fa-viber:before { - content: "\f409"; } - -.fa-video:before { - content: "\f03d"; } - -.fa-video-slash:before { - content: "\f4e2"; } - -.fa-vihara:before { - content: "\f6a7"; } - -.fa-vimeo:before { - content: "\f40a"; } - -.fa-vimeo-square:before { - content: "\f194"; } - -.fa-vimeo-v:before { - content: "\f27d"; } - -.fa-vine:before { - content: "\f1ca"; } - -.fa-virus:before { - content: "\f974"; } - -.fa-virus-slash:before { - content: "\f975"; } - -.fa-viruses:before { - content: "\f976"; } - -.fa-vk:before { - content: "\f189"; } - -.fa-vnv:before { - content: "\f40b"; } - -.fa-voicemail:before { - content: "\f897"; } - -.fa-volleyball-ball:before { - content: "\f45f"; } - -.fa-volume-down:before { - content: "\f027"; } - -.fa-volume-mute:before { - content: "\f6a9"; } - -.fa-volume-off:before { - content: "\f026"; } - -.fa-volume-up:before { - content: "\f028"; } - -.fa-vote-yea:before { - content: "\f772"; } - -.fa-vr-cardboard:before { - content: "\f729"; } - -.fa-vuejs:before { - content: "\f41f"; } - -.fa-walking:before { - content: "\f554"; } - -.fa-wallet:before { - content: "\f555"; } - -.fa-warehouse:before { - content: "\f494"; } - -.fa-water:before { - content: "\f773"; } - -.fa-wave-square:before { - content: "\f83e"; } - -.fa-waze:before { - content: "\f83f"; } - -.fa-weebly:before { - content: "\f5cc"; } - -.fa-weibo:before { - content: "\f18a"; } - -.fa-weight:before { - content: "\f496"; } - -.fa-weight-hanging:before { - content: "\f5cd"; } - -.fa-weixin:before { - content: "\f1d7"; } - -.fa-whatsapp:before { - content: "\f232"; } - -.fa-whatsapp-square:before { - content: "\f40c"; } - -.fa-wheelchair:before { - content: "\f193"; } - -.fa-whmcs:before { - content: "\f40d"; } - -.fa-wifi:before { - content: "\f1eb"; } - -.fa-wikipedia-w:before { - content: "\f266"; } - -.fa-wind:before { - content: "\f72e"; } - -.fa-window-close:before { - content: "\f410"; } - -.fa-window-maximize:before { - content: "\f2d0"; } - -.fa-window-minimize:before { - content: "\f2d1"; } - -.fa-window-restore:before { - content: "\f2d2"; } - -.fa-windows:before { - content: "\f17a"; } - -.fa-wine-bottle:before { - content: "\f72f"; } - -.fa-wine-glass:before { - content: "\f4e3"; } - -.fa-wine-glass-alt:before { - content: "\f5ce"; } - -.fa-wix:before { - content: "\f5cf"; } - -.fa-wizards-of-the-coast:before { - content: "\f730"; } - -.fa-wolf-pack-battalion:before { - content: "\f514"; } - -.fa-won-sign:before { - content: "\f159"; } - -.fa-wordpress:before { - content: "\f19a"; } - -.fa-wordpress-simple:before { - content: "\f411"; } - -.fa-wpbeginner:before { - content: "\f297"; } - -.fa-wpexplorer:before { - content: "\f2de"; } - -.fa-wpforms:before { - content: "\f298"; } - -.fa-wpressr:before { - content: "\f3e4"; } - -.fa-wrench:before { - content: "\f0ad"; } - -.fa-x-ray:before { - content: "\f497"; } - -.fa-xbox:before { - content: "\f412"; } - -.fa-xing:before { - content: "\f168"; } - -.fa-xing-square:before { - content: "\f169"; } - -.fa-y-combinator:before { - content: "\f23b"; } - -.fa-yahoo:before { - content: "\f19e"; } - -.fa-yammer:before { - content: "\f840"; } - -.fa-yandex:before { - content: "\f413"; } - -.fa-yandex-international:before { - content: "\f414"; } - -.fa-yarn:before { - content: "\f7e3"; } - -.fa-yelp:before { - content: "\f1e9"; } - -.fa-yen-sign:before { - content: "\f157"; } - -.fa-yin-yang:before { - content: "\f6ad"; } - -.fa-yoast:before { - content: "\f2b1"; } - -.fa-youtube:before { - content: "\f167"; } - -.fa-youtube-square:before { - content: "\f431"; } - -.fa-zhihu:before { - content: "\f63f"; } - -.sr-only { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; } - -.sr-only-focusable:active, .sr-only-focusable:focus { - clip: auto; - height: auto; - margin: 0; - overflow: visible; - position: static; - width: auto; } -@font-face { - font-family: 'Font Awesome 5 Brands'; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-brands-400.eot"); - src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); } - -.fab { - font-family: 'Font Awesome 5 Brands'; - font-weight: 400; } -@font-face { - font-family: 'Font Awesome 5 Free'; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-regular-400.eot"); - src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); } - -.far { - font-family: 'Font Awesome 5 Free'; - font-weight: 400; } -@font-face { - font-family: 'Font Awesome 5 Free'; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-solid-900.eot"); - src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); } - -.fa, -.fas { - font-family: 'Font Awesome 5 Free'; - font-weight: 900; } diff --git a/html/css/bulma.css.map b/html/css/bulma.css.map deleted file mode 100644 index 650ec93..0000000 --- a/html/css/bulma.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../bulma.sass","../sass/utilities/animations.sass","bulma.css","../sass/utilities/mixins.sass","../sass/utilities/initial-variables.sass","../sass/utilities/controls.sass","../sass/base/minireset.sass","../sass/base/generic.sass","../sass/base/helpers.sass","../sass/elements/box.sass","../sass/elements/button.sass","../sass/utilities/functions.sass","../sass/elements/container.sass","../sass/elements/content.sass","../sass/elements/icon.sass","../sass/elements/image.sass","../sass/elements/notification.sass","../sass/elements/progress.sass","../sass/elements/table.sass","../sass/elements/tag.sass","../sass/elements/title.sass","../sass/elements/other.sass","../sass/form/shared.sass","../sass/form/input-textarea.sass","../sass/form/checkbox-radio.sass","../sass/form/select.sass","../sass/form/file.sass","../sass/form/tools.sass","../sass/components/breadcrumb.sass","../sass/components/card.sass","../sass/components/dropdown.sass","../sass/components/level.sass","../sass/components/list.sass","../sass/components/media.sass","../sass/components/menu.sass","../sass/components/message.sass","../sass/components/modal.sass","../sass/components/navbar.sass","../sass/components/pagination.sass","../sass/components/panel.sass","../sass/components/tabs.sass","../sass/grid/columns.sass","../sass/grid/tiles.sass","../sass/layout/hero.sass","../sass/layout/section.sass","../sass/layout/footer.sass"],"names":[],"mappings":"AACA,6DAAA;ACDA;EACE;IACE,uBAAuB;ECEzB;EDDA;IACE,yBAAyB;ECG3B;AACF;ADRA;EACE;IACE,uBAAuB;ECEzB;EDDA;IACE,yBAAyB;ECG3B;AACF;;ACmIA;;;;EANE,2BAA2B;EAC3B,yBAAyB;EACzB,sBAAsB;EACtB,qBAAqB;EACrB,iBAAiB;ADtHnB;;AC2IA;EAfE,6BAD8B;EAE9B,kBAAkB;EAClB,eAAe;EACf,aAAa;EACb,YAAY;EACZ,cAAc;EACd,eAAe;EACf,qBAAqB;EACrB,oBAAoB;EACpB,kBAAkB;EAClB,QAAQ;EACR,yBAAyB;EACzB,wBAAwB;EACxB,cAAc;ADxHhB;;AC8HE;;EACE,qBCnHkB;AFPtB;;ACiMA;EAhEE,qBAAqB;EACrB,wBAAwB;EACxB,uCC1K2B;ED2K3B,YAAY;EACZ,uBCvGuB;EDwGvB,eAAe;EACf,oBAAoB;EACpB,qBAAqB;EACrB,YAAY;EACZ,cAAc;EACd,YAAY;EACZ,YAAY;EACZ,gBAAgB;EAChB,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,aAAa;EACb,kBAAkB;EAClB,mBAAmB;EACnB,WAAW;AD7Hb;;AC8HE;EAEE,uBCjL2B;EDkL3B,WAAW;EACX,cAAc;EACd,SAAS;EACT,kBAAkB;EAClB,QAAQ;EACR,0DAA0D;EAC1D,+BAA+B;AD5HnC;;AC6HE;EACE,WAAW;EACX,UAAU;AD1Hd;;AC2HE;EACE,WAAW;EACX,UAAU;ADxHd;;ACyHE;EAEE,uCC9MyB;AFuF7B;;ACwHE;EACE,uCChNyB;AF2F7B;;ACuHE;EACE,YAAY;EACZ,gBAAgB;EAChB,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,WAAW;ADpHf;;ACqHE;EACE,YAAY;EACZ,gBAAgB;EAChB,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,WAAW;ADlHf;;ACmHE;EACE,YAAY;EACZ,gBAAgB;EAChB,eAAe;EACf,gBAAgB;EAChB,eAAe;EACf,WAAW;ADhHf;;ACiIA;EAXE,mDAA2C;UAA3C,2CAA2C;EAC3C,yBCrO4B;EDsO5B,uBCzKuB;ED0KvB,+BAA+B;EAC/B,6BAA6B;EAC7B,WAAW;EACX,cAAc;EACd,WAAW;EACX,kBAAkB;EAClB,UAAU;ADlHZ;;AC8HA;;;;;;;;;;;;;;;;;EANE,SADuB;EAEvB,OAFuB;EAGvB,kBAAkB;EAClB,QAJuB;EAKvB,MALuB;AD/FzB;;AGtHA;;;;;EA3BE,qBAAqB;EACrB,wBAAwB;EACxB,mBAAmB;EACnB,6BAA+C;EAC/C,kBDqDU;ECpDV,gBAAgB;EAChB,oBAAoB;EACpB,eDkBW;ECjBX,aAfoB;EAgBpB,2BAA2B;EAC3B,gBAhBuB;EAiBvB,iCAf+D;EAgB/D,gCAfkE;EAgBlE,iCAhBkE;EAiBlE,8BAlB+D;EAmB/D,kBAAkB;EAClB,mBAAmB;AHyJrB;;AGvJE;;;;;;;;;;;;;;;;;EAIE,aAAa;AHuKjB;;AGtKE;;;;;;;;;;;;;;;;EAEE,mBAAmB;AHuLvB;;AI5NA,0EAAA;AAEA;;;;;;;;;;;;;;;;;;;;;;;EAuBE,SAAS;EACT,UAAU;AJ8NZ;;AI3NA;;;;;;EAME,eAAe;EACf,mBAAmB;AJ8NrB;;AI3NA;EACE,gBAAgB;AJ8NlB;;AI3NA;;;;EAIE,SAAS;AJ8NX;;AI3NA;EACE,sBAAsB;AJ8NxB;;AI5NA;EAII,mBAAmB;AJ4NvB;;AIzNA;;EAEE,YAAY;EACZ,eAAe;AJ4NjB;;AIzNA;EACE,SAAS;AJ4NX;;AIzNA;EACE,yBAAyB;EACzB,iBAAiB;AJ4NnB;;AI1NA;;EAEE,UAAU;AJ6NZ;;AI/NA;;EAII,gBAAgB;AJgOpB;;AI5PA;EClBE,uBHjB6B;EGkB7B,eAhCc;EAiCd,kCAAkC;EAClC,mCAAmC;EACnC,gBAlCoB;EAmCpB,kBAhCsB;EAiCtB,kBAhCsB;EAiCtB,kCApCiC;EAqCjC,8BAAsB;KAAtB,2BAAsB;MAAtB,0BAAsB;UAAtB,sBAAsB;ALkRxB;;AKhRA;;;;;;;EAOE,cAAc;ALmRhB;;AKjRA;;;;;EAKE,oLH5ByL;AFgT3L;;AKlRA;;EAEE,6BAA6B;EAC7B,4BAA4B;EAC5B,sBHjC0B;AFsT5B;;AKnRA;EACE,cH1D4B;EG2D5B,cAzDkB;EA0DlB,gBH1BiB;EG2BjB,gBAzDoB;AL+UtB;;AKlRA;EACE,cHnDgC;EGoDhC,eAAe;EACf,qBAAqB;ALqRvB;;AKxRA;EAKI,mBAAmB;ALuRvB;;AK5RA;EAOI,cHzE0B;AFkW9B;;AKvRA;EACE,4BHrE4B;EGsE5B,cH3D+B;EG4D/B,kBApEiB;EAqEjB,mBAtEkB;EAuElB,4BAxEgC;ALkWlC;;AKxRA;EACE,4BH5E4B;EG6E5B,YAAY;EACZ,cAAc;EACd,WAvEa;EAwEb,gBAvEkB;ALkWpB;;AKzRA;EACE,YAAY;EACZ,eAAe;AL4RjB;;AK1RA;;EAEE,wBAAwB;AL6R1B;;AK3RA;EACE,kBAtFuB;ALoXzB;;AK5RA;EACE,mBAAmB;EACnB,oBAAoB;AL+RtB;;AK7RA;EACE,cHzG4B;EG0G5B,gBHpEe;AFoWjB;;AK5RA;EACE,YAAY;AL+Rd;;AK7RA;EJzDE,iCAAiC;EI2DjC,4BH5G4B;EG6G5B,cHnH4B;EGoH5B,kBAhGqB;EAiGrB,gBAAgB;EAChB,uBAjG0B;EAkG1B,gBAAgB;EAChB,iBAAiB;ALgSnB;;AKxSA;EAUI,6BAA6B;EAC7B,mBAAmB;EACnB,cAtGoB;EAuGpB,UAAU;ALkSd;;AKhSA;;EAGI,mBAAmB;ALkSvB;;AKrSA;;EAKM,gBAAgB;ALqStB;;AK1SA;EAOI,cHvI0B;AF8a9B;;ACjbE;EACE,WAAW;EACX,YAAY;EACZ,cAAc;ADoblB;;AMrbA;EACE,sBAAsB;ANwbxB;;AMtbA;EACE,uBAAuB;ANybzB;;AMrbA;EACE,2BAA2B;ANwb7B;;AM5aI;EACE,0BAA2B;AN+ajC;;AMhbI;EACE,4BAA2B;ANmbjC;;AMpbI;EACE,0BAA2B;ANubjC;;AMxbI;EACE,4BAA2B;AN2bjC;;AM5bI;EACE,6BAA2B;AN+bjC;;AMhcI;EACE,0BAA2B;ANmcjC;;AMpcI;EACE,6BAA2B;ANucjC;;ACjZE;EKvDE;IACE,0BAA2B;EN4c/B;EM7cE;IACE,4BAA2B;EN+c/B;EMhdE;IACE,0BAA2B;ENkd/B;EMndE;IACE,4BAA2B;ENqd/B;EMtdE;IACE,6BAA2B;ENwd/B;EMzdE;IACE,0BAA2B;EN2d/B;EM5dE;IACE,6BAA2B;EN8d/B;AACF;;ACraE;EK3DE;IACE,0BAA2B;ENoe/B;EMreE;IACE,4BAA2B;ENue/B;EMxeE;IACE,0BAA2B;EN0e/B;EM3eE;IACE,4BAA2B;EN6e/B;EM9eE;IACE,6BAA2B;ENgf/B;EMjfE;IACE,0BAA2B;ENmf/B;EMpfE;IACE,6BAA2B;ENsf/B;AACF;;ACrbE;EKnEE;IACE,0BAA2B;EN4f/B;EM7fE;IACE,4BAA2B;EN+f/B;EMhgBE;IACE,0BAA2B;ENkgB/B;EMngBE;IACE,4BAA2B;ENqgB/B;EMtgBE;IACE,6BAA2B;ENwgB/B;EMzgBE;IACE,0BAA2B;EN2gB/B;EM5gBE;IACE,6BAA2B;EN8gB/B;AACF;;ACzcE;EKvEE;IACE,0BAA2B;ENohB/B;EMrhBE;IACE,4BAA2B;ENuhB/B;EMxhBE;IACE,0BAA2B;EN0hB/B;EM3hBE;IACE,4BAA2B;EN6hB/B;EM9hBE;IACE,6BAA2B;ENgiB/B;EMjiBE;IACE,0BAA2B;ENmiB/B;EMpiBE;IACE,6BAA2B;ENsiB/B;AACF;;ACldI;EKtFA;IACE,0BAA2B;EN4iB/B;EM7iBE;IACE,4BAA2B;EN+iB/B;EMhjBE;IACE,0BAA2B;ENkjB/B;EMnjBE;IACE,4BAA2B;ENqjB/B;EMtjBE;IACE,6BAA2B;ENwjB/B;EMzjBE;IACE,0BAA2B;EN2jB/B;EM5jBE;IACE,6BAA2B;EN8jB/B;AACF;;AC3dI;EKrGA;IACE,0BAA2B;ENokB/B;EMrkBE;IACE,4BAA2B;ENukB/B;EMxkBE;IACE,0BAA2B;EN0kB/B;EM3kBE;IACE,4BAA2B;EN6kB/B;EM9kBE;IACE,6BAA2B;ENglB/B;EMjlBE;IACE,0BAA2B;ENmlB/B;EMplBE;IACE,6BAA2B;ENslB/B;AACF;;AM9jBE;EACE,6BAAqC;ANikBzC;;AMlkBE;EACE,8BAAqC;ANqkBzC;;AMtkBE;EACE,2BAAqC;ANykBzC;;AM1kBE;EACE,4BAAqC;AN6kBzC;;ACjjBE;EKxBE;IACE,6BAAqC;EN6kBzC;AACF;;ACnjBE;EKzBE;IACE,6BAAqC;ENglBzC;AACF;;ACrjBE;EK1BE;IACE,6BAAqC;ENmlBzC;AACF;;ACvjBE;EK3BE;IACE,6BAAqC;ENslBzC;AACF;;ACzjBE;EK5BE;IACE,6BAAqC;ENylBzC;AACF;;AC1jBI;EK9BA;IACE,6BAAqC;EN4lBzC;AACF;;ACtjBI;EKrCA;IACE,6BAAqC;EN+lBzC;AACF;;ACvjBI;EKvCA;IACE,6BAAqC;ENkmBzC;AACF;;ACnjBI;EK9CA;IACE,6BAAqC;ENqmBzC;AACF;;ACvmBE;EKxBE;IACE,8BAAqC;ENmoBzC;AACF;;ACzmBE;EKzBE;IACE,8BAAqC;ENsoBzC;AACF;;AC3mBE;EK1BE;IACE,8BAAqC;ENyoBzC;AACF;;AC7mBE;EK3BE;IACE,8BAAqC;EN4oBzC;AACF;;AC/mBE;EK5BE;IACE,8BAAqC;EN+oBzC;AACF;;AChnBI;EK9BA;IACE,8BAAqC;ENkpBzC;AACF;;AC5mBI;EKrCA;IACE,8BAAqC;ENqpBzC;AACF;;AC7mBI;EKvCA;IACE,8BAAqC;ENwpBzC;AACF;;ACzmBI;EK9CA;IACE,8BAAqC;EN2pBzC;AACF;;AC7pBE;EKxBE;IACE,2BAAqC;ENyrBzC;AACF;;AC/pBE;EKzBE;IACE,2BAAqC;EN4rBzC;AACF;;ACjqBE;EK1BE;IACE,2BAAqC;EN+rBzC;AACF;;ACnqBE;EK3BE;IACE,2BAAqC;ENksBzC;AACF;;ACrqBE;EK5BE;IACE,2BAAqC;ENqsBzC;AACF;;ACtqBI;EK9BA;IACE,2BAAqC;ENwsBzC;AACF;;AClqBI;EKrCA;IACE,2BAAqC;EN2sBzC;AACF;;ACnqBI;EKvCA;IACE,2BAAqC;EN8sBzC;AACF;;AC/pBI;EK9CA;IACE,2BAAqC;ENitBzC;AACF;;ACntBE;EKxBE;IACE,4BAAqC;EN+uBzC;AACF;;ACrtBE;EKzBE;IACE,4BAAqC;ENkvBzC;AACF;;ACvtBE;EK1BE;IACE,4BAAqC;ENqvBzC;AACF;;ACztBE;EK3BE;IACE,4BAAqC;ENwvBzC;AACF;;AC3tBE;EK5BE;IACE,4BAAqC;EN2vBzC;AACF;;AC5tBI;EK9BA;IACE,4BAAqC;EN8vBzC;AACF;;ACxtBI;EKrCA;IACE,4BAAqC;ENiwBzC;AACF;;ACztBI;EKvCA;IACE,4BAAqC;ENowBzC;AACF;;ACrtBI;EK9CA;IACE,4BAAqC;ENuwBzC;AACF;;AMtwBA;EACE,qCAAqC;ANywBvC;;AMvwBA;EACE,oCAAoC;AN0wBtC;;AMxwBA;EACE,oCAAoC;AN2wBtC;;AMzwBA;EACE,6BAA6B;AN4wB/B;;AMxwBE;EACE,uBAAwB;AN2wB5B;;AM1wBE;EAGI,yBAA0C;AN2wBhD;;AM1wBE;EACE,kCAAmC;AN6wBvC;;AMpxBE;EACE,yBAAwB;ANuxB5B;;AMtxBE;EAGI,uBAA0C;ANuxBhD;;AMtxBE;EACE,oCAAmC;ANyxBvC;;AMhyBE;EACE,4BAAwB;ANmyB5B;;AMlyBE;EAGI,yBAA0C;ANmyBhD;;AMlyBE;EACE,uCAAmC;ANqyBvC;;AM5yBE;EACE,yBAAwB;AN+yB5B;;AM9yBE;EAGI,yBAA0C;AN+yBhD;;AM9yBE;EACE,oCAAmC;ANizBvC;;AMxzBE;EACE,yBAAwB;AN2zB5B;;AM1zBE;EAGI,yBAA0C;AN2zBhD;;AM1zBE;EACE,oCAAmC;AN6zBvC;;AMp0BE;EACE,yBAAwB;ANu0B5B;;AMt0BE;EAGI,yBAA0C;ANu0BhD;;AMt0BE;EACE,oCAAmC;ANy0BvC;;AMh1BE;EACE,yBAAwB;ANm1B5B;;AMl1BE;EAGI,yBAA0C;ANm1BhD;;AMl1BE;EACE,oCAAmC;ANq1BvC;;AM51BE;EACE,yBAAwB;AN+1B5B;;AM91BE;EAGI,yBAA0C;AN+1BhD;;AM91BE;EACE,oCAAmC;ANi2BvC;;AMx2BE;EACE,yBAAwB;AN22B5B;;AM12BE;EAGI,yBAA0C;AN22BhD;;AM12BE;EACE,oCAAmC;AN62BvC;;AMp3BE;EACE,yBAAwB;ANu3B5B;;AMt3BE;EAGI,yBAA0C;ANu3BhD;;AMt3BE;EACE,oCAAmC;ANy3BvC;;AMt3BE;EACE,yBAAwB;ANy3B5B;;AMx3BE;EACE,oCAAmC;AN23BvC;;AM93BE;EACE,yBAAwB;ANi4B5B;;AMh4BE;EACE,oCAAmC;ANm4BvC;;AMt4BE;EACE,yBAAwB;ANy4B5B;;AMx4BE;EACE,oCAAmC;AN24BvC;;AM94BE;EACE,yBAAwB;ANi5B5B;;AMh5BE;EACE,oCAAmC;ANm5BvC;;AMt5BE;EACE,yBAAwB;ANy5B5B;;AMx5BE;EACE,oCAAmC;AN25BvC;;AM95BE;EACE,yBAAwB;ANi6B5B;;AMh6BE;EACE,oCAAmC;ANm6BvC;;AMt6BE;EACE,yBAAwB;ANy6B5B;;AMx6BE;EACE,oCAAmC;AN26BvC;;AM96BE;EACE,4BAAwB;ANi7B5B;;AMh7BE;EACE,uCAAmC;ANm7BvC;;AMt7BE;EACE,yBAAwB;ANy7B5B;;AMx7BE;EACE,oCAAmC;AN27BvC;;AMz7BA;EACE,2BAAqC;AN47BvC;;AM37BA;EACE,2BAAsC;AN87BxC;;AM77BA;EACE,2BAAsC;ANg8BxC;;AM/7BA;EACE,2BAAwC;ANk8B1C;;AMj8BA;EACE,2BAAoC;ANo8BtC;;AMl8BA;EACE,+LAAuC;ANq8BzC;;AMn8BA;EACE,+LAAyC;ANs8B3C;;AMp8BA;EACE,+LAA0C;ANu8B5C;;AMr8BA;EACE,iCAAyC;ANw8B3C;;AMt8BA;EACE,iCAAoC;ANy8BtC;;AMl8BE;EACE,yBAA+B;ANq8BnC;;ACrgCE;EKkEE;IACE,yBAA+B;ENu8BnC;AACF;;ACvgCE;EKiEE;IACE,yBAA+B;EN08BnC;AACF;;ACzgCE;EKgEE;IACE,yBAA+B;EN68BnC;AACF;;AC3gCE;EK+DE;IACE,yBAA+B;ENg9BnC;AACF;;AC7gCE;EK8DE;IACE,yBAA+B;ENm9BnC;AACF;;AC9gCI;EK4DA;IACE,yBAA+B;ENs9BnC;AACF;;AC1gCI;EKqDA;IACE,yBAA+B;ENy9BnC;AACF;;AC3gCI;EKmDA;IACE,yBAA+B;EN49BnC;AACF;;ACvgCI;EK4CA;IACE,yBAA+B;EN+9BnC;AACF;;AM5/BE;EACE,wBAA+B;AN+/BnC;;AC/jCE;EKkEE;IACE,wBAA+B;ENigCnC;AACF;;ACjkCE;EKiEE;IACE,wBAA+B;ENogCnC;AACF;;ACnkCE;EKgEE;IACE,wBAA+B;ENugCnC;AACF;;ACrkCE;EK+DE;IACE,wBAA+B;EN0gCnC;AACF;;ACvkCE;EK8DE;IACE,wBAA+B;EN6gCnC;AACF;;ACxkCI;EK4DA;IACE,wBAA+B;ENghCnC;AACF;;ACpkCI;EKqDA;IACE,wBAA+B;ENmhCnC;AACF;;ACrkCI;EKmDA;IACE,wBAA+B;ENshCnC;AACF;;ACjkCI;EK4CA;IACE,wBAA+B;ENyhCnC;AACF;;AMtjCE;EACE,0BAA+B;ANyjCnC;;ACznCE;EKkEE;IACE,0BAA+B;EN2jCnC;AACF;;AC3nCE;EKiEE;IACE,0BAA+B;EN8jCnC;AACF;;AC7nCE;EKgEE;IACE,0BAA+B;ENikCnC;AACF;;AC/nCE;EK+DE;IACE,0BAA+B;ENokCnC;AACF;;ACjoCE;EK8DE;IACE,0BAA+B;ENukCnC;AACF;;ACloCI;EK4DA;IACE,0BAA+B;EN0kCnC;AACF;;AC9nCI;EKqDA;IACE,0BAA+B;EN6kCnC;AACF;;AC/nCI;EKmDA;IACE,0BAA+B;ENglCnC;AACF;;AC3nCI;EK4CA;IACE,0BAA+B;ENmlCnC;AACF;;AMhnCE;EACE,gCAA+B;ANmnCnC;;ACnrCE;EKkEE;IACE,gCAA+B;ENqnCnC;AACF;;ACrrCE;EKiEE;IACE,gCAA+B;ENwnCnC;AACF;;ACvrCE;EKgEE;IACE,gCAA+B;EN2nCnC;AACF;;ACzrCE;EK+DE;IACE,gCAA+B;EN8nCnC;AACF;;AC3rCE;EK8DE;IACE,gCAA+B;ENioCnC;AACF;;AC5rCI;EK4DA;IACE,gCAA+B;ENooCnC;AACF;;ACxrCI;EKqDA;IACE,gCAA+B;ENuoCnC;AACF;;ACzrCI;EKmDA;IACE,gCAA+B;EN0oCnC;AACF;;ACrrCI;EK4CA;IACE,gCAA+B;EN6oCnC;AACF;;AM1qCE;EACE,+BAA+B;AN6qCnC;;AC7uCE;EKkEE;IACE,+BAA+B;EN+qCnC;AACF;;AC/uCE;EKiEE;IACE,+BAA+B;ENkrCnC;AACF;;ACjvCE;EKgEE;IACE,+BAA+B;ENqrCnC;AACF;;ACnvCE;EK+DE;IACE,+BAA+B;ENwrCnC;AACF;;ACrvCE;EK8DE;IACE,+BAA+B;EN2rCnC;AACF;;ACtvCI;EK4DA;IACE,+BAA+B;EN8rCnC;AACF;;AClvCI;EKqDA;IACE,+BAA+B;ENisCnC;AACF;;ACnvCI;EKmDA;IACE,+BAA+B;ENosCnC;AACF;;AC/uCI;EK4CA;IACE,+BAA+B;ENusCnC;AACF;;AMtsCA;EACE,wBAAwB;ANysC1B;;AMvsCA;EACE,uBAAuB;EACvB,iCAAiC;EACjC,yBAAyB;EACzB,2BAA2B;EAC3B,qBAAqB;EACrB,6BAA6B;EAC7B,8BAA8B;EAC9B,wBAAwB;AN0sC1B;;AClzCE;EK2GA;IACE,wBAAwB;EN2sC1B;AACF;;ACpzCE;EK2GA;IACE,wBAAwB;EN6sC1B;AACF;;ACtzCE;EK2GA;IACE,wBAAwB;EN+sC1B;AACF;;ACxzCE;EK2GA;IACE,wBAAwB;ENitC1B;AACF;;AC1zCE;EK2GA;IACE,wBAAwB;ENmtC1B;AACF;;AC3zCI;EK0GF;IACE,wBAAwB;ENqtC1B;AACF;;ACvzCI;EKoGF;IACE,wBAAwB;ENutC1B;AACF;;ACxzCI;EKmGF;IACE,wBAAwB;ENytC1B;AACF;;ACpzCI;EK6FF;IACE,wBAAwB;EN2tC1B;AACF;;AM1tCA;EACE,6BAA6B;AN6tC/B;;AC52CE;EKkJA;IACE,6BAA6B;EN8tC/B;AACF;;AC92CE;EKkJA;IACE,6BAA6B;ENguC/B;AACF;;ACh3CE;EKkJA;IACE,6BAA6B;ENkuC/B;AACF;;ACl3CE;EKkJA;IACE,6BAA6B;ENouC/B;AACF;;ACp3CE;EKkJA;IACE,6BAA6B;ENsuC/B;AACF;;ACr3CI;EKiJF;IACE,6BAA6B;ENwuC/B;AACF;;ACj3CI;EK2IF;IACE,6BAA6B;EN0uC/B;AACF;;ACl3CI;EK0IF;IACE,6BAA6B;EN4uC/B;AACF;;AC92CI;EKoIF;IACE,6BAA6B;EN8uC/B;AACF;;AM3uCA;EACE,oBAAoB;AN8uCtB;;AM5uCA;EACE,qBAAqB;AN+uCvB;;AM7uCA;EACE,2BAA2B;ANgvC7B;;AM9uCA;EACE,2BAA2B;ANivC7B;;AM5uCA;EACE,6BAA6B;AN+uC/B;;AO9/CA;EAEE,uBLI6B;EKH7B,kBL0DgB;EKzDhB,0FLX2B;EKY3B,cLP4B;EKQ5B,cAAc;EACd,gBAZmB;AP4gDrB;;AO9/CA;EAGI,yELC8B;AF8/ClC;;AOlgDA;EAKI,oELD8B;AFkgDlC;;AQ1+CA;EAGE,uBNlC6B;EMmC7B,qBNxC4B;EMyC5B,iBLhDwB;EKiDxB,cN9C4B;EM+C5B,eAAe;EAGf,uBAAuB;EACvB,iCAlD6D;EAmD7D,iBAlD6B;EAmD7B,kBAnD6B;EAoD7B,8BArD6D;EAsD7D,kBAAkB;EAClB,mBAAmB;ARy+CrB;;AQz/CA;EAkBI,cAAc;AR2+ClB;;AQ7/CA;EAwBM,aAAa;EACb,YAAY;ARy+ClB;;AQlgDA;EA2BM,+BAAmF;EACnF,oBAA4C;AR2+ClD;;AQvgDA;EA8BM,mBAA2C;EAC3C,gCAAoF;AR6+C1F;;AQ5gDA;EAiCM,+BAAmF;EACnF,gCAAoF;AR++C1F;;AQjhDA;EAsCI,qBN3E0B;EM4E1B,cN/E0B;AF8jD9B;;AQthDA;EA0CI,qBNlE8B;EMmE9B,cNnF0B;AFmkD9B;;AQ3hDA;EA6CM,kDNrE4B;AFujDlC;;AQ/hDA;EAgDI,qBNvF0B;EMwF1B,cNzF0B;AF4kD9B;;AQpiDA;EAoDI,6BAA6B;EAC7B,yBAAyB;EACzB,cN7F0B;EM8F1B,0BA/E8B;ARmkDlC;;AQ3iDA;EA4DM,4BN7FwB;EM8FxB,cNrGwB;AFwlD9B;;AQhjDA;EAgEM,yBCD2B;EDE3B,cNzGwB;AF6lD9B;;AQrjDA;;EAoEM,6BAA6B;EAC7B,yBAAyB;EACzB,gBAAgB;ARs/CtB;;AQ5jDA;EA2EM,uBN1GyB;EM2GzB,yBAAyB;EACzB,cNzHuB;AF8mD7B;;AQlkDA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,cN9HqB;AFonD7B;;AQxkDA;EAqFQ,yBAAyB;EACzB,cNlIqB;AFynD7B;;AQ7kDA;EAwFU,mDNvHqB;AFgnD/B;;AQjlDA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,cNzIqB;AFmoD7B;;AQvlDA;;EAgGQ,uBN/HuB;EMgIvB,yBAAyB;EACzB,gBAAgB;AR4/CxB;;AQ9lDA;EAoGQ,yBNhJqB;EMiJrB,YNpIuB;AFkoD/B;;AQnmDA;EAwGU,uBCzCuB;ATwiDjC;;AQvmDA;;EA2GU,yBNvJmB;EMwJnB,yBAAyB;EACzB,gBAAgB;EAChB,YN7IqB;AF8oD/B;;AQ/mDA;EAiHU,gEAA4E;ARkgDtF;;AQnnDA;EAmHQ,6BAA6B;EAC7B,mBNnJuB;EMoJvB,YNpJuB;AFwpD/B;;AQznDA;EA0HU,uBNzJqB;EM0JrB,mBN1JqB;EM2JrB,cNxKmB;AF2qD7B;;AQ/nDA;EA+HY,4DAA8D;ARogD1E;;AQnoDA;EAqIc,gEAA4E;ARkgD1F;;AQvoDA;;EAwIU,6BAA6B;EAC7B,mBNxKqB;EMyKrB,gBAAgB;EAChB,YN1KqB;AF8qD/B;;AQ/oDA;EA6IQ,6BAA6B;EAC7B,qBN1LqB;EM2LrB,cN3LqB;AFisD7B;;AQrpDA;EAoJU,yBNhMmB;EMiMnB,YNpLqB;AFyrD/B;;AQ1pDA;EA4Jc,4DAA8D;ARkgD5E;;AQ9pDA;;EA+JU,6BAA6B;EAC7B,qBN5MmB;EM6MnB,gBAAgB;EAChB,cN9MmB;AFktD7B;;AQtqDA;EA2EM,yBNvHuB;EMwHvB,yBAAyB;EACzB,YN5GyB;AF2sD/B;;AQ5qDA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,YNjHuB;AFitD/B;;AQlrDA;EAqFQ,yBAAyB;EACzB,YNrHuB;AFstD/B;;AQvrDA;EAwFU,gDNpImB;AFuuD7B;;AQ3rDA;EA2FQ,uBC5ByB;ED6BzB,yBAAyB;EACzB,YN5HuB;AFguD/B;;AQjsDA;;EAgGQ,yBN5IqB;EM6IrB,yBAAyB;EACzB,gBAAgB;ARsmDxB;;AQxsDA;EAoGQ,uBNnIuB;EMoIvB,cNjJqB;AFyvD7B;;AQ7sDA;EAwGU,yBCzCuB;ATkpDjC;;AQjtDA;;EA2GU,uBN1IqB;EM2IrB,yBAAyB;EACzB,gBAAgB;EAChB,cN1JmB;AFqwD7B;;AQztDA;EAiHU,4DAA4E;AR4mDtF;;AQ7tDA;EAmHQ,6BAA6B;EAC7B,qBNhKqB;EMiKrB,cNjKqB;AF+wD7B;;AQnuDA;EA0HU,yBNtKmB;EMuKnB,qBNvKmB;EMwKnB,YN3JqB;AFwwD/B;;AQzuDA;EA+HY,gEAA8D;AR8mD1E;;AQ7uDA;EAqIc,4DAA4E;AR4mD1F;;AQjvDA;;EAwIU,6BAA6B;EAC7B,qBNrLmB;EMsLnB,gBAAgB;EAChB,cNvLmB;AFqyD7B;;AQzvDA;EA6IQ,6BAA6B;EAC7B,mBN7KuB;EM8KvB,YN9KuB;AF8xD/B;;AQ/vDA;EAoJU,uBNnLqB;EMoLrB,cNjMmB;AFgzD7B;;AQpwDA;EA4Jc,gEAA8D;AR4mD5E;;AQxwDA;;EA+JU,6BAA6B;EAC7B,mBN/LqB;EMgMrB,gBAAgB;EAChB,YNjMqB;AF+yD/B;;AQhxDA;EA2EM,4BN5GwB;EM6GxB,yBAAyB;EACzB,yBC3Ce;ATovDrB;;AQtxDA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,yBChDa;AT0vDrB;;AQ5xDA;EAqFQ,yBAAyB;EACzB,yBCpDa;AT+vDrB;;AQjyDA;EAwFU,mDNzHoB;AFs0D9B;;AQryDA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,yBC3Da;ATywDrB;;AQ3yDA;;EAgGQ,4BNjIsB;EMkItB,yBAAyB;EACzB,gBAAgB;ARgtDxB;;AQlzDA;EAoGQ,oCClEa;EDmEb,iBNtIsB;AFw1D9B;;AQvzDA;EAwGU,oCCzCuB;AT4vDjC;;AQ3zDA;;EA2GU,oCCzEW;ED0EX,yBAAyB;EACzB,gBAAgB;EAChB,iBN/IoB;AFo2D9B;;AQn0DA;EAiHU,sFAA4E;ARstDtF;;AQv0DA;EAmHQ,6BAA6B;EAC7B,wBNrJsB;EMsJtB,iBNtJsB;AF82D9B;;AQ70DA;EA0HU,4BN3JoB;EM4JpB,wBN5JoB;EM6JpB,yBC1FW;ATizDrB;;AQn1DA;EA+HY,sEAA8D;ARwtD1E;;AQv1DA;EAqIc,sFAA4E;ARstD1F;;AQ31DA;;EAwIU,6BAA6B;EAC7B,wBN1KoB;EM2KpB,gBAAgB;EAChB,iBN5KoB;AFo4D9B;;AQn2DA;EA6IQ,6BAA6B;EAC7B,gCC5Ga;ED6Gb,yBC7Ga;ATu0DrB;;AQz2DA;EAoJU,oCClHW;EDmHX,iBNtLoB;AF+4D9B;;AQ92DA;EA4Jc,sEAA8D;ARstD5E;;AQl3DA;;EA+JU,6BAA6B;EAC7B,gCC9HW;ED+HX,gBAAgB;EAChB,yBChIW;ATw1DrB;;AQ13DA;EA2EM,yBNnHwB;EMoHxB,yBAAyB;EACzB,WCzCU;AT41DhB;;AQh4DA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;ATk2DhB;;AQt4DA;EAqFQ,yBAAyB;EACzB,WClDQ;ATu2DhB;;AQ34DA;EAwFU,gDNhIoB;AFu7D9B;;AQ/4DA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;ATi3DhB;;AQr5DA;;EAgGQ,yBNxIsB;EMyItB,yBAAyB;EACzB,gBAAgB;AR0zDxB;;AQ55DA;EAoGQ,sBChEQ;EDiER,cN7IsB;AFy8D9B;;AQj6DA;EAwGU,yBCzCuB;ATs2DjC;;AQr6DA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNtJoB;AFq9D9B;;AQ76DA;EAiHU,0DAA4E;ARg0DtF;;AQj7DA;EAmHQ,6BAA6B;EAC7B,qBN5JsB;EM6JtB,cN7JsB;AF+9D9B;;AQv7DA;EA0HU,yBNlKoB;EMmKpB,qBNnKoB;EMoKpB,WCxFM;ATy5DhB;;AQ77DA;EA+HY,gEAA8D;ARk0D1E;;AQj8DA;EAqIc,0DAA4E;ARg0D1F;;AQr8DA;;EAwIU,6BAA6B;EAC7B,qBNjLoB;EMkLpB,gBAAgB;EAChB,cNnLoB;AFq/D9B;;AQ78DA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;AT+6DhB;;AQn9DA;EAoJU,sBChHM;EDiHN,cN7LoB;AFggE9B;;AQx9DA;EA4Jc,gEAA8D;ARg0D5E;;AQ59DA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;ATg8DhB;;AQp+DA;EA2EM,yBNrG4B;EMsG5B,yBAAyB;EACzB,WCzCU;ATs8DhB;;AQ1+DA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;AT48DhB;;AQh/DA;EAqFQ,yBAAyB;EACzB,WClDQ;ATi9DhB;;AQr/DA;EAwFU,iDNlHwB;AFmhElC;;AQz/DA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;AT29DhB;;AQ//DA;;EAgGQ,yBN1H0B;EM2H1B,yBAAyB;EACzB,gBAAgB;ARo6DxB;;AQtgEA;EAoGQ,sBChEQ;EDiER,cN/H0B;AFqiElC;;AQ3gEA;EAwGU,yBCzCuB;ATg9DjC;;AQ/gEA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNxIwB;AFijElC;;AQvhEA;EAiHU,0DAA4E;AR06DtF;;AQ3hEA;EAmHQ,6BAA6B;EAC7B,qBN9I0B;EM+I1B,cN/I0B;AF2jElC;;AQjiEA;EA0HU,yBNpJwB;EMqJxB,qBNrJwB;EMsJxB,WCxFM;ATmgEhB;;AQviEA;EA+HY,gEAA8D;AR46D1E;;AQ3iEA;EAqIc,0DAA4E;AR06D1F;;AQ/iEA;;EAwIU,6BAA6B;EAC7B,qBNnKwB;EMoKxB,gBAAgB;EAChB,cNrKwB;AFilElC;;AQvjEA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;ATyhEhB;;AQ7jEA;EAoJU,sBChHM;EDiHN,cN/KwB;AF4lElC;;AQlkEA;EA4Jc,gEAA8D;AR06D5E;;AQtkEA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;AT0iEhB;;AQ9kEA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;AT+hErE;;AQnlEA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;ATqiErE;;AQzlEA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;AT2iErE;;AQ/lEA;EA2EM,yBNnG4B;EMoG5B,yBAAyB;EACzB,WCzCU;ATikEhB;;AQrmEA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;ATukEhB;;AQ3mEA;EAqFQ,yBAAyB;EACzB,WClDQ;AT4kEhB;;AQhnEA;EAwFU,kDNhHwB;AF4oElC;;AQpnEA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;ATslEhB;;AQ1nEA;;EAgGQ,yBNxH0B;EMyH1B,yBAAyB;EACzB,gBAAgB;AR+hExB;;AQjoEA;EAoGQ,sBChEQ;EDiER,cN7H0B;AF8pElC;;AQtoEA;EAwGU,yBCzCuB;AT2kEjC;;AQ1oEA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNtIwB;AF0qElC;;AQlpEA;EAiHU,0DAA4E;ARqiEtF;;AQtpEA;EAmHQ,6BAA6B;EAC7B,qBN5I0B;EM6I1B,cN7I0B;AForElC;;AQ5pEA;EA0HU,yBNlJwB;EMmJxB,qBNnJwB;EMoJxB,WCxFM;AT8nEhB;;AQlqEA;EA+HY,gEAA8D;ARuiE1E;;AQtqEA;EAqIc,0DAA4E;ARqiE1F;;AQ1qEA;;EAwIU,6BAA6B;EAC7B,qBNjKwB;EMkKxB,gBAAgB;EAChB,cNnKwB;AF0sElC;;AQlrEA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;ATopEhB;;AQxrEA;EAoJU,sBChHM;EDiHN,cN7KwB;AFqtElC;;AQ7rEA;EA4Jc,gEAA8D;ARqiE5E;;AQjsEA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;ATqqEhB;;AQzsEA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;AT0pErE;;AQ9sEA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;ATgqErE;;AQptEA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;ATsqErE;;AQ1tEA;EA2EM,yBNpG4B;EMqG5B,yBAAyB;EACzB,WCzCU;AT4rEhB;;AQhuEA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;ATksEhB;;AQtuEA;EAqFQ,yBAAyB;EACzB,WClDQ;ATusEhB;;AQ3uEA;EAwFU,kDNjHwB;AFwwElC;;AQ/uEA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;ATitEhB;;AQrvEA;;EAgGQ,yBNzH0B;EM0H1B,yBAAyB;EACzB,gBAAgB;AR0pExB;;AQ5vEA;EAoGQ,sBChEQ;EDiER,cN9H0B;AF0xElC;;AQjwEA;EAwGU,yBCzCuB;ATssEjC;;AQrwEA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNvIwB;AFsyElC;;AQ7wEA;EAiHU,0DAA4E;ARgqEtF;;AQjxEA;EAmHQ,6BAA6B;EAC7B,qBN7I0B;EM8I1B,cN9I0B;AFgzElC;;AQvxEA;EA0HU,yBNnJwB;EMoJxB,qBNpJwB;EMqJxB,WCxFM;ATyvEhB;;AQ7xEA;EA+HY,gEAA8D;ARkqE1E;;AQjyEA;EAqIc,0DAA4E;ARgqE1F;;AQryEA;;EAwIU,6BAA6B;EAC7B,qBNlKwB;EMmKxB,gBAAgB;EAChB,cNpKwB;AFs0ElC;;AQ7yEA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;AT+wEhB;;AQnzEA;EAoJU,sBChHM;EDiHN,cN9KwB;AFi1ElC;;AQxzEA;EA4Jc,gEAA8D;ARgqE5E;;AQ5zEA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;ATgyEhB;;AQp0EA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;ATqxErE;;AQz0EA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;AT2xErE;;AQ/0EA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;ATiyErE;;AQr1EA;EA2EM,yBNtG4B;EMuG5B,yBAAyB;EACzB,WCzCU;ATuzEhB;;AQ31EA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;AT6zEhB;;AQj2EA;EAqFQ,yBAAyB;EACzB,WClDQ;ATk0EhB;;AQt2EA;EAwFU,kDNnHwB;AFq4ElC;;AQ12EA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;AT40EhB;;AQh3EA;;EAgGQ,yBN3H0B;EM4H1B,yBAAyB;EACzB,gBAAgB;ARqxExB;;AQv3EA;EAoGQ,sBChEQ;EDiER,cNhI0B;AFu5ElC;;AQ53EA;EAwGU,yBCzCuB;ATi0EjC;;AQh4EA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNzIwB;AFm6ElC;;AQx4EA;EAiHU,0DAA4E;AR2xEtF;;AQ54EA;EAmHQ,6BAA6B;EAC7B,qBN/I0B;EMgJ1B,cNhJ0B;AF66ElC;;AQl5EA;EA0HU,yBNrJwB;EMsJxB,qBNtJwB;EMuJxB,WCxFM;ATo3EhB;;AQx5EA;EA+HY,gEAA8D;AR6xE1E;;AQ55EA;EAqIc,0DAA4E;AR2xE1F;;AQh6EA;;EAwIU,6BAA6B;EAC7B,qBNpKwB;EMqKxB,gBAAgB;EAChB,cNtKwB;AFm8ElC;;AQx6EA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;AT04EhB;;AQ96EA;EAoJU,sBChHM;EDiHN,cNhLwB;AF88ElC;;AQn7EA;EA4Jc,gEAA8D;AR2xE5E;;AQv7EA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;AT25EhB;;AQ/7EA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;ATg5ErE;;AQp8EA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;ATs5ErE;;AQ18EA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;AT45ErE;;AQh9EA;EA2EM,yBNvG4B;EMwG5B,yBAAyB;EACzB,yBC3Ce;ATo7ErB;;AQt9EA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,yBChDa;AT07ErB;;AQ59EA;EAqFQ,yBAAyB;EACzB,yBCpDa;AT+7ErB;;AQj+EA;EAwFU,kDNpHwB;AFigFlC;;AQr+EA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,yBC3Da;ATy8ErB;;AQ3+EA;;EAgGQ,yBN5H0B;EM6H1B,yBAAyB;EACzB,gBAAgB;ARg5ExB;;AQl/EA;EAoGQ,oCClEa;EDmEb,cNjI0B;AFmhFlC;;AQv/EA;EAwGU,oCCzCuB;AT47EjC;;AQ3/EA;;EA2GU,oCCzEW;ED0EX,yBAAyB;EACzB,gBAAgB;EAChB,cN1IwB;AF+hFlC;;AQngFA;EAiHU,sFAA4E;ARs5EtF;;AQvgFA;EAmHQ,6BAA6B;EAC7B,qBNhJ0B;EMiJ1B,cNjJ0B;AFyiFlC;;AQ7gFA;EA0HU,yBNtJwB;EMuJxB,qBNvJwB;EMwJxB,yBC1FW;ATi/ErB;;AQnhFA;EA+HY,gEAA8D;ARw5E1E;;AQvhFA;EAqIc,sFAA4E;ARs5E1F;;AQ3hFA;;EAwIU,6BAA6B;EAC7B,qBNrKwB;EMsKxB,gBAAgB;EAChB,cNvKwB;AF+jFlC;;AQniFA;EA6IQ,6BAA6B;EAC7B,gCC5Ga;ED6Gb,yBC7Ga;ATugFrB;;AQziFA;EAoJU,oCClHW;EDmHX,cNjLwB;AF0kFlC;;AQ9iFA;EA4Jc,gEAA8D;ARs5E5E;;AQljFA;;EA+JU,6BAA6B;EAC7B,gCC9HW;ED+HX,gBAAgB;EAChB,yBChIW;ATwhFrB;;AQ1jFA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;AT2gFrE;;AQ/jFA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;ATihFrE;;AQrkFA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;ATuhFrE;;AQ3kFA;EA2EM,yBNjG2B;EMkG3B,yBAAyB;EACzB,WCzCU;AT6iFhB;;AQjlFA;EAgFQ,yBCjByB;EDkBzB,yBAAyB;EACzB,WC9CQ;ATmjFhB;;AQvlFA;EAqFQ,yBAAyB;EACzB,WClDQ;ATwjFhB;;AQ5lFA;EAwFU,kDN9GuB;AFsnFjC;;AQhmFA;EA2FQ,yBC5ByB;ED6BzB,yBAAyB;EACzB,WCzDQ;ATkkFhB;;AQtmFA;;EAgGQ,yBNtHyB;EMuHzB,yBAAyB;EACzB,gBAAgB;AR2gFxB;;AQ7mFA;EAoGQ,sBChEQ;EDiER,cN3HyB;AFwoFjC;;AQlnFA;EAwGU,yBCzCuB;ATujFjC;;AQtnFA;;EA2GU,sBCvEM;EDwEN,yBAAyB;EACzB,gBAAgB;EAChB,cNpIuB;AFopFjC;;AQ9nFA;EAiHU,0DAA4E;ARihFtF;;AQloFA;EAmHQ,6BAA6B;EAC7B,qBN1IyB;EM2IzB,cN3IyB;AF8pFjC;;AQxoFA;EA0HU,yBNhJuB;EMiJvB,qBNjJuB;EMkJvB,WCxFM;AT0mFhB;;AQ9oFA;EA+HY,gEAA8D;ARmhF1E;;AQlpFA;EAqIc,0DAA4E;ARihF1F;;AQtpFA;;EAwIU,6BAA6B;EAC7B,qBN/JuB;EMgKvB,gBAAgB;EAChB,cNjKuB;AForFjC;;AQ9pFA;EA6IQ,6BAA6B;EAC7B,kBC1GQ;ED2GR,WC3GQ;ATgoFhB;;AQpqFA;EAoJU,sBChHM;EDiHN,cN3KuB;AF+rFjC;;AQzqFA;EA4Jc,gEAA8D;ARihF5E;;AQ7qFA;;EA+JU,6BAA6B;EAC7B,kBC5HM;ED6HN,gBAAgB;EAChB,WC9HM;ATipFhB;;AQrrFA;EAwKU,yBC7HsC;ED8HtC,cCrH2D;ATsoFrE;;AQ1rFA;EA4KY,yBC7GqB;ED8GrB,yBAAyB;EACzB,cC1HyD;AT4oFrE;;AQhsFA;EAiLY,yBClHqB;EDmHrB,yBAAyB;EACzB,cC/HyD;ATkpFrE;;AQtsFA;EATE,kBN+BgB;EM9BhB,kBNAc;AFmtFhB;;AQ3sFA;EANE,eNHW;AFwtFb;;AQ/sFA;EAJE,kBNNc;AF6tFhB;;AQntFA;EAFE,iBNTa;AFkuFf;;AQvtFA;;EAgMI,uBN/N2B;EMgO3B,qBNrO0B;EMsO1B,gBApNyB;EAqNzB,YApNyB;ARgvF7B;;AQ/tFA;EAqMI,aAAa;EACb,WAAW;AR8hFf;;AQpuFA;EAwMI,6BAA6B;EAC7B,oBAAoB;ARgiFxB;;AQzuFA;EPrCE,kBAAkB;EAKhB,2BAAiC;EACjC,0BAAgC;EO4O9B,6BAA6B;ARmiFnC;;AQhvFA;EA+MI,4BNhP0B;EMiP1B,qBNpP0B;EMqP1B,cNvP0B;EMwP1B,gBAAgB;EAChB,oBAAoB;ARqiFxB;;AQxvFA;EAqNI,uBN5LqB;EM6LrB,gCAA0D;EAC1D,iCAA2D;ARuiF/D;;AQriFA;EACE,mBAAmB;EACnB,aAAa;EACb,eAAe;EACf,2BAA2B;ARwiF7B;;AQ5iFA;EAMI,qBAAqB;AR0iFzB;;AQhjFA;EAQM,oBAAoB;AR4iF1B;;AQpjFA;EAUI,sBAAsB;AR8iF1B;;AQxjFA;EAYI,mBAAmB;ARgjFvB;;AQ5jFA;EAlOE,kBN+BgB;EM9BhB,kBNAc;AFkyFhB;;AQjkFA;EA7NE,kBNNc;AFwyFhB;;AQrkFA;EA3NE,iBNTa;AF6yFf;;AQzkFA;EA0BQ,4BAA4B;EAC5B,yBAAyB;ARmjFjC;;AQ9kFA;EA6BQ,6BAA6B;EAC7B,0BAA0B;EAC1B,kBAAkB;ARqjF1B;;AQplFA;EAiCQ,eAAe;ARujFvB;;AQxlFA;EAoCQ,UAAU;ARwjFlB;;AQ5lFA;EA0CQ,UAAU;ARsjFlB;;AQhmFA;EA4CU,UAAU;ARwjFpB;;AQpmFA;EA8CQ,YAAY;EACZ,cAAc;AR0jFtB;;AQzmFA;EAiDI,uBAAuB;AR4jF3B;;AQ7mFA;EAoDQ,oBAAoB;EACpB,qBAAqB;AR6jF7B;;AQlnFA;EAuDI,yBAAyB;AR+jF7B;;AQtnFA;EA0DQ,oBAAoB;EACpB,qBAAqB;ARgkF7B;;AUh4FA;EACE,YAAY;EACZ,cAAc;EACd,kBAAkB;EAClB,WAAW;AVm4Fb;;AUv4FA;EAMI,eAAe;EACf,kBR4CM;EQ3CN,mBR2CM;EQ1CN,WAAW;AVq4Ff;;AC/yFE;ES/FF;IAWI,gBAAuC;EVw4FzC;AACF;;AC3yFI;ESzGJ;IAcM,iBAA0C;EV24F9C;AACF;;AClyFI;ESxHJ;IAiBM,iBAAsC;EV84F1C;AACF;;AClzFI;ES9GJ;IAmBI,iBAA0C;EVk5F5C;AACF;;ACzyFI;ES7HJ;IAqBI,iBAAsC;EVs5FxC;AACF;;AW35FA;EAII,kBAAkB;AX25FtB;;AW/5FA;;;;;;;EAcM,kBAAkB;AX25FxB;;AWz6FA;;;;;;EAqBI,cTlC0B;ESmC1B,gBTEiB;ESDjB,kBAxC+B;AXq8FnC;;AWp7FA;EAyBI,cAAc;EACd,oBAAoB;AX+5FxB;;AWz7FA;EA4BM,eAAe;AXi6FrB;;AW77FA;EA8BI,iBAAiB;EACjB,uBAAuB;AXm6F3B;;AWl8FA;EAiCM,oBAAoB;AXq6F1B;;AWt8FA;EAmCI,gBAAgB;EAChB,uBAAuB;AXu6F3B;;AW38FA;EAsCM,oBAAoB;AXy6F1B;;AW/8FA;EAwCI,iBAAiB;EACjB,oBAAoB;AX26FxB;;AWp9FA;EA2CI,kBAAkB;EAClB,uBAAuB;AX66F3B;;AWz9FA;EA8CI,cAAc;EACd,kBAAkB;AX+6FtB;;AW99FA;EAiDI,4BTvD0B;ESwD1B,8BT3D0B;ES4D1B,qBAhEqC;AXi/FzC;;AWp+FA;EAqDI,4BAA4B;EAC5B,gBAAgB;EAChB,eAAe;AXm7FnB;;AW1+FA;EAyDM,wBAAwB;AXq7F9B;;AW9+FA;EA2DQ,4BAA4B;AXu7FpC;;AWl/FA;EA6DQ,4BAA4B;AXy7FpC;;AWt/FA;EA+DQ,4BAA4B;AX27FpC;;AW1/FA;EAiEQ,4BAA4B;AX67FpC;;AW9/FA;EAmEI,wBAAwB;EACxB,gBAAgB;EAChB,eAAe;AX+7FnB;;AWpgGA;EAuEM,uBAAuB;EACvB,iBAAiB;AXi8FvB;;AWzgGA;EA0EQ,uBAAuB;AXm8F/B;;AW7gGA;EA4EI,gBAAgB;AXq8FpB;;AWjhGA;EA8EI,gBAAgB;EAChB,iBAAiB;EACjB,kBAAkB;AXu8FtB;;AWvhGA;EAkFM,eAAe;AXy8FrB;;AW3hGA;EAoFM,kBAAkB;AX28FxB;;AW/hGA;EAsFM,qBAAqB;AX68F3B;;AWniGA;EAwFM,kBAAkB;AX+8FxB;;AWviGA;EV2CE,iCAAiC;EUgD/B,gBAAgB;EAChB,qBAvG8B;EAwG9B,gBAAgB;EAChB,iBAAiB;AXi9FrB;;AW/iGA;;EAiGI,cAAc;AXm9FlB;;AWpjGA;EAmGI,WAAW;AXq9Ff;;AWxjGA;;EAsGM,yBT/GwB;ESgHxB,qBA/GmC;EAgHnC,qBA/GmC;EAgHnC,mBAAmB;AXu9FzB;;AWhkGA;EA2GM,cTxHwB;AFilG9B;;AWpkGA;EA6GQ,gBAAgB;AX29FxB;;AWxkGA;;EAiHQ,qBAtHsC;EAuHtC,cT/HsB;AF2lG9B;;AW9kGA;;EAsHQ,qBAzHsC;EA0HtC,cTpIsB;AFimG9B;;AWplGA;;EA6HY,sBAAsB;AX49FlC;;AWzlGA;EAgIM,aAAa;AX69FnB;;AW7lGA;EAmII,kBThHY;AF8kGhB;;AWjmGA;EAqII,kBTpHY;AFolGhB;;AWrmGA;EAuII,iBTvHW;AFylGf;;AYvnGA;EACE,mBAAmB;EACnB,oBAAoB;EACpB,uBAAuB;EACvB,cATsB;EAUtB,aAVsB;AZooGxB;;AY/nGA;EAQI,YAZwB;EAaxB,WAbwB;AZwoG5B;;AYpoGA;EAWI,YAdyB;EAezB,WAfyB;AZ4oG7B;;AYzoGA;EAcI,YAhBwB;EAiBxB,WAjBwB;AZgpG5B;;AajpGA;EACE,cAAc;EACd,kBAAkB;AbopGpB;;AatpGA;EAII,cAAc;EACd,YAAY;EACZ,WAAW;AbspGf;;Aa5pGA;EAQM,uBX6DmB;AF2lGzB;;AahqGA;EAUI,WAAW;Ab0pGf;;AapqGA;;;;;;;;;;;;;;;;;EA+BM,YAAY;EACZ,WAAW;AbypGjB;;AazrGA;EAmCI,iBAAiB;Ab0pGrB;;Aa7rGA;EAqCI,gBAAgB;Ab4pGpB;;AajsGA;EAuCI,gBAAgB;Ab8pGpB;;AarsGA;EAyCI,qBAAqB;AbgqGzB;;AazsGA;EA2CI,gBAAgB;AbkqGpB;;Aa7sGA;EA6CI,mBAAmB;AboqGvB;;AajtGA;EA+CI,gBAAgB;AbsqGpB;;AartGA;EAiDI,qBAAqB;AbwqGzB;;AaztGA;EAmDI,iBAAiB;Ab0qGrB;;Aa7tGA;EAqDI,sBAAsB;Ab4qG1B;;AajuGA;EAuDI,iBAAiB;Ab8qGrB;;AaruGA;EAyDI,sBAAsB;AbgrG1B;;AazuGA;EA2DI,sBAAsB;AbkrG1B;;Aa7uGA;EA6DI,iBAAiB;AborGrB;;AajvGA;EA+DI,iBAAiB;AbsrGrB;;AarvGA;EAmEM,YAAwB;EACxB,WAAuB;AbsrG7B;;Aa1vGA;EAmEM,YAAwB;EACxB,WAAuB;Ab2rG7B;;Aa/vGA;EAmEM,YAAwB;EACxB,WAAuB;AbgsG7B;;AapwGA;EAmEM,YAAwB;EACxB,WAAuB;AbqsG7B;;AazwGA;EAmEM,YAAwB;EACxB,WAAuB;Ab0sG7B;;Aa9wGA;EAmEM,YAAwB;EACxB,WAAuB;Ab+sG7B;;AanxGA;EAmEM,aAAwB;EACxB,YAAuB;AbotG7B;;AcrxGA;EAEE,4BZM4B;EYL5B,kBZ6DU;EY5DV,sCANkD;EAOlD,kBAAkB;AduxGpB;;Ac5xGA;EAOI,mBAAmB;EACnB,0BAA0B;AdyxG9B;;AcjyGA;EAUI,mBAAmB;Ad2xGvB;;AcryGA;;EAaI,iBZH2B;AFgyG/B;;Ac1yGA;EAeI,uBAAuB;Ad+xG3B;;Ac9yGA;EAiBI,kBAAkB;EAClB,aAAa;EACb,WAAW;AdiyGf;;AcpzGA;;;EAuBI,mBAAmB;AdmyGvB;;Ac1zGA;EA6BM,uBZnByB;EYoBzB,cZjCuB;AFk0G7B;;Ac/zGA;EA6BM,yBZhCuB;EYiCvB,YZpByB;AF0zG/B;;Acp0GA;EA6BM,4BZrBwB;EYsBxB,yBL6Ce;AT8vGrB;;Acz0GA;EA6BM,yBZ5BwB;EY6BxB,WL+CU;ATiwGhB;;Ac90GA;EA6BM,yBZd4B;EYe5B,WL+CU;ATswGhB;;Acn1GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;AT2vGrE;;Acx1GA;EA6BM,yBZZ4B;EYa5B,WL+CU;ATgxGhB;;Ac71GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;ATqwGrE;;Acl2GA;EA6BM,yBZb4B;EYc5B,WL+CU;AT0xGhB;;Acv2GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;AT+wGrE;;Ac52GA;EA6BM,yBZf4B;EYgB5B,WL+CU;AToyGhB;;Acj3GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;ATyxGrE;;Act3GA;EA6BM,yBZhB4B;EYiB5B,yBL6Ce;ATgzGrB;;Ac33GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;ATmyGrE;;Ach4GA;EA6BM,yBZV2B;EYW3B,WL+CU;ATwzGhB;;Acr4GA;EAoCU,yBLgDsC;EK/CtC,cLwD2D;AT6yGrE;;Aez4GA;EAEE,qBAAqB;EACrB,wBAAwB;EACxB,YAAY;EACZ,uBb4DuB;Ea3DvB,cAAc;EACd,YbwBW;EavBX,gBAAgB;EAChB,UAAU;EACV,WAAW;Af24Gb;;Aer5GA;EAYI,yBbP2B;AFo5G/B;;Aez5GA;EAcI,yBbb0B;AF45G9B;;Ae75GA;EAgBI,yBbf0B;AFg6G9B;;Aej6GA;EAkBI,yBbjB0B;EakB1B,YAAY;Afm5GhB;;Aet6GA;EAyBQ,uBbhBuB;AFi6G/B;;Ae16GA;EA2BQ,uBblBuB;AFq6G/B;;Ae96GA;EA6BQ,uBbpBuB;AFy6G/B;;Ael7GA;EA+BQ,mEAA2F;Afu5GnG;;Aet7GA;EAyBQ,yBb7BqB;AF87G7B;;Ae17GA;EA2BQ,yBb/BqB;AFk8G7B;;Ae97GA;EA6BQ,yBbjCqB;AFs8G7B;;Ael8GA;EA+BQ,qEAA2F;Afu6GnG;;Aet8GA;EAyBQ,4BblBsB;AFm8G9B;;Ae18GA;EA2BQ,4BbpBsB;AFu8G9B;;Ae98GA;EA6BQ,4BbtBsB;AF28G9B;;Ael9GA;EA+BQ,wEAA2F;Afu7GnG;;Aet9GA;EAyBQ,yBbzBsB;AF09G9B;;Ae19GA;EA2BQ,yBb3BsB;AF89G9B;;Ae99GA;EA6BQ,yBb7BsB;AFk+G9B;;Ael+GA;EA+BQ,qEAA2F;Afu8GnG;;Aet+GA;EAyBQ,yBbX0B;AF49GlC;;Ae1+GA;EA2BQ,yBbb0B;AFg+GlC;;Ae9+GA;EA6BQ,yBbf0B;AFo+GlC;;Ael/GA;EA+BQ,qEAA2F;Afu9GnG;;Aet/GA;EAyBQ,yBbT0B;AF0+GlC;;Ae1/GA;EA2BQ,yBbX0B;AF8+GlC;;Ae9/GA;EA6BQ,yBbb0B;AFk/GlC;;AelgHA;EA+BQ,qEAA2F;Afu+GnG;;AetgHA;EAyBQ,yBbV0B;AF2/GlC;;Ae1gHA;EA2BQ,yBbZ0B;AF+/GlC;;Ae9gHA;EA6BQ,yBbd0B;AFmgHlC;;AelhHA;EA+BQ,qEAA2F;Afu/GnG;;AethHA;EAyBQ,yBbZ0B;AF6gHlC;;Ae1hHA;EA2BQ,yBbd0B;AFihHlC;;Ae9hHA;EA6BQ,yBbhB0B;AFqhHlC;;AeliHA;EA+BQ,qEAA2F;AfugHnG;;AetiHA;EAyBQ,yBbb0B;AF8hHlC;;Ae1iHA;EA2BQ,yBbf0B;AFkiHlC;;Ae9iHA;EA6BQ,yBbjB0B;AFsiHlC;;AeljHA;EA+BQ,qEAA2F;AfuhHnG;;AetjHA;EAyBQ,yBbPyB;AFwiHjC;;Ae1jHA;EA2BQ,yBbTyB;AF4iHjC;;Ae9jHA;EA6BQ,yBbXyB;AFgjHjC;;AelkHA;EA+BQ,qEAA2F;AfuiHnG;;AetkHA;EAkCI,gCApCkC;UAoClC,wBApCkC;EAqClC,2CAAmC;UAAnC,mCAAmC;EACnC,yCAAiC;UAAjC,iCAAiC;EACjC,yCAAiC;UAAjC,iCAAiC;EACjC,yBbjC2B;EakC3B,qEAA0F;EAC1F,6BAA6B;EAC7B,4BAA4B;EAC5B,0BAA0B;AfwiH9B;;AellHA;EA4CM,6BAA6B;Af0iHnC;;AetlHA;EA8CM,6BAA6B;Af4iHnC;;Ae1lHA;EAkDI,eblBY;AF8jHhB;;Ae9lHA;EAoDI,ebtBY;AFokHhB;;AelmHA;EAsDI,cbzBW;AFykHf;;Ae9iHA;EACE;IACE,2BAA2B;EfijH7B;EehjHA;IACE,4BAA4B;EfkjH9B;AACF;;AevjHA;EACE;IACE,2BAA2B;EfijH7B;EehjHA;IACE,4BAA4B;EfkjH9B;AACF;;AgB5lHA;EAEE,uBdZ6B;Eca7B,cdtB4B;AFonH9B;;AgBjmHA;;EAMI,yBdrB0B;EcsB1B,qBA5B6B;EA6B7B,qBA5B6B;EA6B7B,mBAAmB;AhBgmHvB;;AgBzmHA;;EAeQ,uBdzBuB;Ec0BvB,mBd1BuB;Ec2BvB,cdxCqB;AFuoH7B;;AgBhnHA;;EAeQ,yBdtCqB;EcuCrB,qBdvCqB;EcwCrB,Yd3BuB;AFioH/B;;AgBvnHA;;EAeQ,4Bd3BsB;Ec4BtB,wBd5BsB;Ec6BtB,yBPsCa;ATukHrB;;AgB9nHA;;EAeQ,yBdlCsB;EcmCtB,qBdnCsB;EcoCtB,WPwCQ;AT4kHhB;;AgBroHA;;EAeQ,yBdpB0B;EcqB1B,qBdrB0B;EcsB1B,WPwCQ;ATmlHhB;;AgB5oHA;;EAeQ,yBdlB0B;EcmB1B,qBdnB0B;EcoB1B,WPwCQ;AT0lHhB;;AgBnpHA;;EAeQ,yBdnB0B;EcoB1B,qBdpB0B;EcqB1B,WPwCQ;ATimHhB;;AgB1pHA;;EAeQ,yBdrB0B;EcsB1B,qBdtB0B;EcuB1B,WPwCQ;ATwmHhB;;AgBjqHA;;EAeQ,yBdtB0B;EcuB1B,qBdvB0B;EcwB1B,yBPsCa;ATinHrB;;AgBxqHA;;EAeQ,yBdhByB;EciBzB,qBdjByB;EckBzB,WPwCQ;ATsnHhB;;AgB/qHA;;EAoBM,mBAAmB;EACnB,SAAS;AhBgqHf;;AgBrrHA;;EAuBM,yBd5B4B;Ec6B5B,WPiCU;ATkoHhB;;AgB3rHA;;;;EA2BQ,mBAAmB;AhBuqH3B;;AgBlsHA;EA6BI,cdhD0B;AFytH9B;;AgBtsHA;EA+BM,gBAAgB;AhB2qHtB;;AgB1sHA;EAkCM,yBdvC4B;EcwC5B,WPsBU;ATspHhB;;AgB/sHA;;EAsCQ,mBAAmB;AhB8qH3B;;AgBptHA;;EAyCQ,kBPgBQ;EOfR,mBAAmB;AhBgrH3B;;AgB1tHA;EA4CI,6BAxDqC;AhB0uHzC;;AgB9tHA;;EA+CM,qBAhEgC;EAiEhC,cdnEwB;AFuvH9B;;AgBpuHA;EAkDI,6BA5DqC;AhBkvHzC;;AgBxuHA;;EAqDM,qBApEgC;EAqEhC,cdzEwB;AFiwH9B;;AgB9uHA;EAwDI,6BAnEqC;AhB6vHzC;;AgBlvHA;;EA6DU,sBAAsB;AhB0rHhC;;AgBvvHA;;EAkEM,iBAAiB;AhB0rHvB;;AgB5vHA;;EAuEU,wBAAwB;AhB0rHlC;;AgBjwHA;EAyEI,WAAW;AhB4rHf;;AgBrwHA;EA8EU,yBdzFoB;AFoxH9B;;AgBzwHA;EAmFY,yBd9FkB;AFwxH9B;;AgB7wHA;EAqFc,4BdjGgB;AF6xH9B;;AgBjxHA;;EAyFM,qBAAqB;AhB6rH3B;;AgBtxHA;EA8FU,yBdzGoB;AFqyH9B;;AgB1rHA;Ef3DE,iCAAiC;Ee8DjC,cAAc;EACd,kBAAkB;EAClB,eAAe;AhB4rHjB;;AiBrzHA;EACE,mBAAmB;EACnB,aAAa;EACb,eAAe;EACf,2BAA2B;AjBwzH7B;;AiB5zHA;EAMI,qBAAqB;AjB0zHzB;;AiBh0HA;EAQM,oBAAoB;AjB4zH1B;;AiBp0HA;EAUI,sBAAsB;AjB8zH1B;;AiBx0HA;EAYI,mBAAmB;AjBg0HvB;;AiB50HA;EAgBM,efgBO;AFgzHb;;AiBh1HA;EAmBM,kBfYU;AFqzHhB;;AiBp1HA;EAqBI,uBAAuB;AjBm0H3B;;AiBx1HA;EAuBM,qBAAqB;EACrB,oBAAoB;AjBq0H1B;;AiB71HA;EA0BI,yBAAyB;AjBu0H7B;;AiBj2HA;EA6BQ,mBAAmB;AjBw0H3B;;AiBr2HA;EA+BQ,eAAe;AjB00HvB;;AiBz2HA;EAkCM,eAAe;AjB20HrB;;AiB72HA;EAoCQ,cAAc;EACd,4BAA4B;EAC5B,yBAAyB;AjB60HjC;;AiBn3HA;EAwCQ,6BAA6B;EAC7B,0BAA0B;AjB+0HlC;;AiB70HA;EACE,mBAAmB;EACnB,4BfrC4B;EesC5B,kBfkBU;EejBV,cf7C4B;Ee8C5B,oBAAoB;EACpB,kBfhBc;EeiBd,WAAW;EACX,uBAAuB;EACvB,gBAAgB;EAChB,oBAAoB;EACpB,qBAAqB;EACrB,mBAAmB;AjBg1HrB;;AiB51HA;EAcI,oBAAoB;EACpB,uBAAuB;AjBk1H3B;;AiBj2HA;EAqBM,uBftDyB;EeuDzB,cfpEuB;AFo5H7B;;AiBt2HA;EAqBM,yBfnEuB;EeoEvB,YfvDyB;AF44H/B;;AiB32HA;EAqBM,4BfxDwB;EeyDxB,yBRUe;ATg1HrB;;AiBh3HA;EAqBM,yBf/DwB;EegExB,WRYU;ATm1HhB;;AiBr3HA;EAqBM,yBfjD4B;EekD5B,WRYU;ATw1HhB;;AiB13HA;EA4BU,yBRasC;EQZtC,cRqB2D;AT60HrE;;AiB/3HA;EAqBM,yBf/C4B;EegD5B,WRYU;ATk2HhB;;AiBp4HA;EA4BU,yBRasC;EQZtC,cRqB2D;ATu1HrE;;AiBz4HA;EAqBM,yBfhD4B;EeiD5B,WRYU;AT42HhB;;AiB94HA;EA4BU,yBRasC;EQZtC,cRqB2D;ATi2HrE;;AiBn5HA;EAqBM,yBflD4B;EemD5B,WRYU;ATs3HhB;;AiBx5HA;EA4BU,yBRasC;EQZtC,cRqB2D;AT22HrE;;AiB75HA;EAqBM,yBfnD4B;EeoD5B,yBRUe;ATk4HrB;;AiBl6HA;EA4BU,yBRasC;EQZtC,cRqB2D;ATq3HrE;;AiBv6HA;EAqBM,yBf7C2B;Ee8C3B,WRYU;AT04HhB;;AiB56HA;EA4BU,yBRasC;EQZtC,cRqB2D;AT+3HrE;;AiBj7HA;EAgCI,kBf1CY;AF+7HhB;;AiBr7HA;EAkCI,ef7CS;AFo8Hb;;AiBz7HA;EAoCI,kBfhDY;AFy8HhB;;AiB77HA;EAuCM,qBAAqB;EACrB,sBAAsB;AjB05H5B;;AiBl8HA;EA0CM,qBAAqB;EACrB,sBAAsB;AjB45H5B;;AiBv8HA;EA6CM,qBAAqB;EACrB,sBAAsB;AjB85H5B;;AiB58HA;EAiDI,gBA9FmB;EA+FnB,UAAU;EACV,kBAAkB;EAClB,UAAU;AjB+5Hd;;AiBn9HA;EAuDM,8BAA8B;EAC9B,WAAW;EACX,cAAc;EACd,SAAS;EACT,kBAAkB;EAClB,QAAQ;EACR,0DAA0D;EAC1D,+BAA+B;AjBg6HrC;;AiB99HA;EAgEM,WAAW;EACX,UAAU;AjBk6HhB;;AiBn+HA;EAmEM,WAAW;EACX,UAAU;AjBo6HhB;;AiBx+HA;EAuEM,yBAAmD;AjBq6HzD;;AiB5+HA;EAyEM,yBAAoD;AjBu6H1D;;AiBh/HA;EA2EI,uBfpDqB;AF69HzB;;AiBv6HA;EAEI,0BAA0B;AjBy6H9B;;AkBrhIA;;EAGE,sBAAsB;AlBuhIxB;;AkB1hIA;;;;EAMI,oBAAoB;AlB2hIxB;;AkBjiIA;;EAQI,iBApBmB;AlBkjIvB;;AkBtiIA;;EAUI,iBArBmB;AlBsjIvB;;AkB3iIA;;EAYI,sBAAsB;AlBoiI1B;;AkBliIA;EACE,chB5B4B;EgB+B5B,ehBHW;EgBIX,gBhBKmB;EgBJnB,kBAnCuB;AlBskIzB;;AkBziIA;EAQI,cApCwB;EAqCxB,oBApCyB;AlBykI7B;;AkB9iIA;EAWI,oBAAoB;AlBuiIxB;;AkBljIA;EAaI,oBA7B+B;AlBskInC;;AkBtjIA;EAkBM,ehBnBO;AF2jIb;;AkB1jIA;EAkBM,iBhBlBS;AF8jIf;;AkB9jIA;EAkBM,ehBjBO;AFikIb;;AkBlkIA;EAkBM,iBhBhBS;AFokIf;;AkBtkIA;EAkBM,kBhBfU;AFukIhB;;AkB1kIA;EAkBM,ehBdO;AF0kIb;;AkB9kIA;EAkBM,kBhBbU;AF6kIhB;;AkB9jIA;EACE,chB/C4B;EgBkD5B,kBhBrBc;EgBsBd,gBhBjBiB;EgBkBjB,iBA7CyB;AlB4mI3B;;AkBrkIA;EAQI,chBvD0B;EgBwD1B,gBhBnBiB;AFolIrB;;AkB1kIA;EAWI,oBA/C+B;AlBknInC;;AkB9kIA;EAgBM,ehBrCO;AFumIb;;AkBllIA;EAgBM,iBhBpCS;AF0mIf;;AkBtlIA;EAgBM,ehBnCO;AF6mIb;;AkB1lIA;EAgBM,iBhBlCS;AFgnIf;;AkB9lIA;EAgBM,kBhBjCU;AFmnIhB;;AkBlmIA;EAgBM,ehBhCO;AFsnIb;;AkBtmIA;EAgBM,kBhB/BU;AFynIhB;;AmBzpIA;EACE,cAAc;EACd,eAAe;EACf,mBAAmB;EACnB,kBAAkB;EAClB,yBAAyB;AnB4pI3B;;AmB1pIA;EAEE,gBjB0BiB;EiBzBjB,eAAe;EACf,gBAAgB;EAChB,UAAU;AnB4pIZ;;AmBjqIA;EAOI,cAAc;EACd,eAAe;AnB8pInB;;AmBzpIA;EACE,mBAAmB;EACnB,4BjBf4B;EiBgB5B,uBjB0CuB;EiBzCvB,oBAAoB;EACpB,kBjBKc;EiBJd,WAAW;EACX,uBAAuB;EACvB,oBAAoB;EACpB,gBAAgB;EAChB,uBAAuB;EACvB,kBAAkB;EAClB,mBAAmB;AnB4pIrB;;AoB7oIA;EAxBE,uBlBd6B;EkBe7B,qBlBpB4B;EkBqB5B,kBlBsCU;EkBrCV,clB1B4B;AFmsI9B;;ACtoII;EmBjCA,4BlB5B0B;AFusI9B;;AC1oII;EmBjCA,4BlB5B0B;AF2sI9B;;AC9oII;EmBjCA,4BlB5B0B;AF+sI9B;;AClpII;EmBjCA,4BlB5B0B;AFmtI9B;;AoBtrIE;EAEE,qBlB5B0B;AFotI9B;;AoBvrIE;EAIE,qBlBpB8B;EkBqB9B,kDlBrB8B;AF4sIlC;;AoBtrIE;;;;;EAEE,4BlBjC0B;EkBkC1B,wBlBlC0B;EkBmC1B,gBAAgB;EAChB,clBzC0B;AFquI9B;;AC1qII;;;;;EmBhBE,+BlB3CwB;AF6uI9B;;AClrII;;;;;EmBhBE,+BlB3CwB;AFqvI9B;;AC1rII;;;;;EmBhBE,+BlB3CwB;AF6vI9B;;AClsII;;;;;EmBhBE,+BlB3CwB;AFqwI9B;;AqBzwIA;EAEE,2DnBJ2B;EmBK3B,eAAe;EACf,WAAW;ArB2wIb;;AqB1wIE;EACE,gBAAgB;ArB6wIpB;;AqBzwII;EACE,mBnBAyB;AF4wI/B;;AqB7wIK;EAMG,mDnBLuB;AFgxI/B;;AqBjxII;EACE,qBnBbuB;AFiyI7B;;AqBrxIK;EAMG,gDnBlBqB;AFqyI7B;;AqBzxII;EACE,wBnBFwB;AF8xI9B;;AqB7xIK;EAMG,mDnBPsB;AFkyI9B;;AqBjyII;EACE,qBnBTwB;AF6yI9B;;AqBryIK;EAMG,gDnBdsB;AFizI9B;;AqBzyII;EACE,qBnBK4B;AFuyIlC;;AqB7yIK;EAMG,iDnBA0B;AF2yIlC;;AqBjzII;EACE,qBnBO4B;AF6yIlC;;AqBrzIK;EAMG,kDnBE0B;AFizIlC;;AqBzzII;EACE,qBnBM4B;AFszIlC;;AqB7zIK;EAMG,kDnBC0B;AF0zIlC;;AqBj0II;EACE,qBnBI4B;AFg0IlC;;AqBr0IK;EAMG,kDnBD0B;AFo0IlC;;AqBz0II;EACE,qBnBG4B;AFy0IlC;;AqB70IK;EAMG,kDnBF0B;AF60IlC;;AqBj1II;EACE,qBnBS2B;AF20IjC;;AqBr1IK;EAMG,kDnBIyB;AF+0IjC;;AqBj1IE;ElBsBA,kBDwBgB;ECvBhB,kBDPc;AFs0IhB;;AqBp1IE;ElBuBA,kBDXc;AF40IhB;;AqBt1IE;ElBuBA,iBDda;AFi1If;;AqBv1IE;EACE,cAAc;EACd,WAAW;ArB01If;;AqBz1IE;EACE,eAAe;EACf,WAAW;ArB41If;;AqB11IA;EAGI,uBnBgCqB;EmB/BrB,gDAA4D;EAC5D,iDAA6D;ArB21IjE;;AqBh2IA;EAOI,6BAA6B;EAC7B,yBAAyB;EACzB,gBAAgB;EAChB,eAAe;EACf,gBAAgB;ArB61IpB;;AqB31IA;EAEE,cAAc;EACd,eAAe;EACf,eAAe;EACf,2BlB7CkE;EkB8ClE,gBAAgB;ArB61IlB;;AqBn2IA;EAQI,gBAxDsB;EAyDtB,eAxDqB;ArBu5IzB;;AqBx2IA;EAWI,eAAe;ArBi2InB;;AqB52IA;EAcI,YAAY;ArBk2IhB;;AsBj6IA;EACE,eAAe;EACf,qBAAqB;EACrB,iBAAiB;EACjB,kBAAkB;AtBo6IpB;;AsBn6IE;EACE,eAAe;AtBs6InB;;AsBr6IE;EACE,cpBF0B;AF06I9B;;AsBv6IE;;;EAEE,cpBH0B;EoBI1B,mBAAmB;AtB26IvB;;AsBt6IA;EAGI,kBAAkB;AtBu6ItB;;AuB37IA;EACE,qBAAqB;EACrB,eAAe;EACf,kBAAkB;EAClB,mBAAmB;AvB87IrB;;AuBl8IA;EAMI,apBDkB;AHi8ItB;;AuBt8IA;EAUM,qBrBY4B;EqBX5B,cAAc;EACd,UAAU;AvBg8IhB;;AuB58IA;EAeM,uBrBwDmB;EqBvDnB,iBAAiB;AvBi8IvB;;AuBj9IA;EAmBI,eAAe;EACf,cAAc;EACd,cAAc;EACd,eAAe;EACf,aAAa;AvBk8IjB;;AuBz9IA;EAyBM,aAAa;AvBo8InB;;AuB79IA;;EA4BM,wBrBfwB;AFq9I9B;;AuBl+IA;EA8BM,oBAAoB;AvBw8I1B;;AuBt+IA;EAgCM,YAAY;EACZ,UAAU;AvB08IhB;;AuB3+IA;EAmCQ,kBAAkB;AvB48I1B;;AuB/+IA;EAuCM,qBrBjCwB;AF6+I9B;;AuBn/IA;EA6CQ,mBrB9BuB;AFw+I/B;;AuBv/IA;EA+CQ,mBrBhCuB;AF4+I/B;;AuB3/IA;EAkDU,qBd2DuB;ATk5IjC;;AuB//IA;EAuDU,mDrBxCqB;AFo/I/B;;AuBngJA;EA6CQ,qBrB3CqB;AFqgJ7B;;AuBvgJA;EA+CQ,qBrB7CqB;AFygJ7B;;AuB3gJA;EAkDU,mBd2DuB;ATk6IjC;;AuB/gJA;EAuDU,gDrBrDmB;AFihJ7B;;AuBnhJA;EA6CQ,wBrBhCsB;AF0gJ9B;;AuBvhJA;EA+CQ,wBrBlCsB;AF8gJ9B;;AuB3hJA;EAkDU,qBd2DuB;ATk7IjC;;AuB/hJA;EAuDU,mDrB1CoB;AFshJ9B;;AuBniJA;EA6CQ,qBrBvCsB;AFiiJ9B;;AuBviJA;EA+CQ,qBrBzCsB;AFqiJ9B;;AuB3iJA;EAkDU,qBd2DuB;ATk8IjC;;AuB/iJA;EAuDU,gDrBjDoB;AF6iJ9B;;AuBnjJA;EA6CQ,qBrBzB0B;AFmiJlC;;AuBvjJA;EA+CQ,qBrB3B0B;AFuiJlC;;AuB3jJA;EAkDU,qBd2DuB;ATk9IjC;;AuB/jJA;EAuDU,iDrBnCwB;AF+iJlC;;AuBnkJA;EA6CQ,qBrBvB0B;AFijJlC;;AuBvkJA;EA+CQ,qBrBzB0B;AFqjJlC;;AuB3kJA;EAkDU,qBd2DuB;ATk+IjC;;AuB/kJA;EAuDU,kDrBjCwB;AF6jJlC;;AuBnlJA;EA6CQ,qBrBxB0B;AFkkJlC;;AuBvlJA;EA+CQ,qBrB1B0B;AFskJlC;;AuB3lJA;EAkDU,qBd2DuB;ATk/IjC;;AuB/lJA;EAuDU,kDrBlCwB;AF8kJlC;;AuBnmJA;EA6CQ,qBrB1B0B;AFolJlC;;AuBvmJA;EA+CQ,qBrB5B0B;AFwlJlC;;AuB3mJA;EAkDU,qBd2DuB;ATkgJjC;;AuB/mJA;EAuDU,kDrBpCwB;AFgmJlC;;AuBnnJA;EA6CQ,qBrB3B0B;AFqmJlC;;AuBvnJA;EA+CQ,qBrB7B0B;AFymJlC;;AuB3nJA;EAkDU,qBd2DuB;ATkhJjC;;AuB/nJA;EAuDU,kDrBrCwB;AFinJlC;;AuBnoJA;EA6CQ,qBrBrByB;AF+mJjC;;AuBvoJA;EA+CQ,qBrBvByB;AFmnJjC;;AuB3oJA;EAkDU,qBd2DuB;ATkiJjC;;AuB/oJA;EAuDU,kDrB/BuB;AF2nJjC;;AuBnpJA;EpB4CE,kBDwBgB;ECvBhB,kBDPc;AFknJhB;;AuBxpJA;EpB+CE,kBDXc;AFwnJhB;;AuB5pJA;EpBiDE,iBDda;AF6nJf;;AuBhqJA;EAkEM,qBrB1DwB;AF4pJ9B;;AuBpqJA;EAoEI,WAAW;AvBomJf;;AuBxqJA;EAsEM,WAAW;AvBsmJjB;;AuB5qJA;EA0EM,aAAa;EACb,kBAAkB;EAClB,cAAc;EACd,YAAY;EACZ,eAAe;AvBsmJrB;;AuBprJA;EAgFM,kBrB1CU;AFkpJhB;;AuBxrJA;EAkFM,kBrB9CU;AFwpJhB;;AuB5rJA;EAoFM,iBrBjDS;AF6pJf;;AwBnrJA;EAEE,oBAAoB;EACpB,aAAa;EACb,2BAA2B;EAC3B,kBAAkB;AxBqrJpB;;AwB1rJA;EAYQ,uBtBVuB;EsBWvB,yBAAyB;EACzB,ctBzBqB;AF2sJ7B;;AwBhsJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,ctB/BmB;AFitJ7B;;AwBtsJA;EAwBU,yBAAyB;EACzB,+CtBvBqB;EsBwBrB,ctBrCmB;AFutJ7B;;AwB5sJA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,ctB3CmB;AF6tJ7B;;AwBltJA;EAYQ,yBtBvBqB;EsBwBrB,yBAAyB;EACzB,YtBZuB;AFstJ/B;;AwBxtJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,YtBlBqB;AF4tJ/B;;AwB9tJA;EAwBU,yBAAyB;EACzB,4CtBpCmB;EsBqCnB,YtBxBqB;AFkuJ/B;;AwBpuJA;EA8BU,uBfkEuB;EejEvB,yBAAyB;EACzB,YtB9BqB;AFwuJ/B;;AwB1uJA;EAYQ,4BtBZsB;EsBatB,yBAAyB;EACzB,yBfqDa;AT6qJrB;;AwBhvJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,yBf+CW;ATmrJrB;;AwBtvJA;EAwBU,yBAAyB;EACzB,+CtBzBoB;EsB0BpB,yBfyCW;ATyrJrB;;AwB5vJA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,yBfmCW;AT+rJrB;;AwBlwJA;EAYQ,yBtBnBsB;EsBoBtB,yBAAyB;EACzB,WfuDQ;ATmsJhB;;AwBxwJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATysJhB;;AwB9wJA;EAwBU,yBAAyB;EACzB,4CtBhCoB;EsBiCpB,Wf2CM;AT+sJhB;;AwBpxJA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;ATqtJhB;;AwB1xJA;EAYQ,yBtBL0B;EsBM1B,yBAAyB;EACzB,WfuDQ;AT2tJhB;;AwBhyJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATiuJhB;;AwBtyJA;EAwBU,yBAAyB;EACzB,6CtBlBwB;EsBmBxB,Wf2CM;ATuuJhB;;AwB5yJA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;AT6uJhB;;AwBlzJA;EAYQ,yBtBH0B;EsBI1B,yBAAyB;EACzB,WfuDQ;ATmvJhB;;AwBxzJA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATyvJhB;;AwB9zJA;EAwBU,yBAAyB;EACzB,8CtBhBwB;EsBiBxB,Wf2CM;AT+vJhB;;AwBp0JA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;ATqwJhB;;AwB10JA;EAYQ,yBtBJ0B;EsBK1B,yBAAyB;EACzB,WfuDQ;AT2wJhB;;AwBh1JA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATixJhB;;AwBt1JA;EAwBU,yBAAyB;EACzB,8CtBjBwB;EsBkBxB,Wf2CM;ATuxJhB;;AwB51JA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;AT6xJhB;;AwBl2JA;EAYQ,yBtBN0B;EsBO1B,yBAAyB;EACzB,WfuDQ;ATmyJhB;;AwBx2JA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATyyJhB;;AwB92JA;EAwBU,yBAAyB;EACzB,8CtBnBwB;EsBoBxB,Wf2CM;AT+yJhB;;AwBp3JA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;ATqzJhB;;AwB13JA;EAYQ,yBtBP0B;EsBQ1B,yBAAyB;EACzB,yBfqDa;AT6zJrB;;AwBh4JA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,yBf+CW;ATm0JrB;;AwBt4JA;EAwBU,yBAAyB;EACzB,8CtBpBwB;EsBqBxB,yBfyCW;ATy0JrB;;AwB54JA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,yBfmCW;AT+0JrB;;AwBl5JA;EAYQ,yBtBDyB;EsBEzB,yBAAyB;EACzB,WfuDQ;ATm1JhB;;AwBx5JA;EAkBU,yBf8EuB;Ee7EvB,yBAAyB;EACzB,WfiDM;ATy1JhB;;AwB95JA;EAwBU,yBAAyB;EACzB,8CtBduB;EsBevB,Wf2CM;AT+1JhB;;AwBp6JA;EA8BU,yBfkEuB;EejEvB,yBAAyB;EACzB,WfqCM;ATq2JhB;;AwB16JA;EAmCI,kBtBVY;AFq5JhB;;AwB96JA;EAqCI,kBtBdY;AF25JhB;;AwBl7JA;EAwCQ,eAAe;AxB84JvB;;AwBt7JA;EA0CI,iBtBpBW;AFo6Jf;;AwB17JA;EA6CQ,eAAe;AxBi5JvB;;AwB97JA;EAiDM,6BAA6B;EAC7B,0BAA0B;AxBi5JhC;;AwBn8JA;EAoDM,4BAA4B;EAC5B,yBAAyB;AxBm5J/B;;AwBx8JA;EAwDQ,kBtBAI;AFo5JZ;;AwB58JA;EA0DQ,aAAa;AxBs5JrB;;AwBh9JA;EA6DM,sBAAsB;AxBu5J5B;;AwBp9JA;EA+DM,sBAAsB;EACtB,YAAY;EACZ,gBAAgB;AxBy5JtB;;AwB19JA;EAmEM,uBAAuB;AxB25J7B;;AwB99JA;EAqEM,aAAa;EACb,YAAY;AxB65JlB;;AwBn+JA;EAwEQ,eAAe;AxB+5JvB;;AwBv+JA;EA2EQ,eAAe;AxBg6JvB;;AwB3+JA;EA8EQ,eAAe;AxBi6JvB;;AwB/+JA;EAiFQ,eAAe;AxBk6JvB;;AwBn/JA;EAoFQ,0BAA4C;AxBm6JpD;;AwBv/JA;EAsFQ,0BtB9BI;EsB+BJ,uBAAuB;AxBq6J/B;;AwB5/JA;EAyFI,uBAAuB;AxBu6J3B;;AwBhgKA;EA4FM,WAAW;AxBw6JjB;;AwBpgKA;EA8FM,YAAY;EACZ,eAAe;AxB06JrB;;AwBzgKA;EAiGI,yBAAyB;AxB46J7B;;AwB7gKA;EAmGM,0BAA4C;AxB86JlD;;AwBjhKA;EAqGM,0BtB7CM;EsB8CN,2BAA2B;EAC3B,SAAS;AxBg7Jf;;AwB96JA;EACE,oBAAoB;EACpB,aAAa;EACb,eAAe;EACf,2BAA2B;EAC3B,gBAAgB;EAChB,kBAAkB;AxBi7JpB;;AwBv7JA;EASM,yBflB2B;EemB3B,ctB1HwB;AF4iK9B;;AwB57JA;EAYM,qBfrB2B;ATy8JjC;;AwBh8JA;EAeM,yBfxB2B;EeyB3B,ctBhIwB;AFqjK9B;;AwBr8JA;EAkBM,qBf3B2B;ATk9JjC;;AwBr7JA;EACE,YAAY;EACZ,OAAO;EACP,UAAU;EACV,aAAa;EACb,kBAAkB;EAClB,MAAM;EACN,WAAW;AxBw7Jb;;AwBt7JA;;EAGE,qBtB5I4B;EsB6I5B,kBtBlFU;EsBmFV,cAAc;EACd,iBAAiB;EACjB,kBAAkB;EAClB,mBAAmB;AxBw7JrB;;AwBt7JA;EACE,4BtBjJ4B;EsBkJ5B,ctBxJ4B;AFilK9B;;AwBv7JA;EACE,qBtBxJ4B;EsByJ5B,mBA1J4B;EA2J5B,2BA1JoC;EA2JpC,cAAc;EACd,eA3JwB;EA4JxB,gBAAgB;EAChB,gBAAgB;EAChB,uBAAuB;AxB07JzB;;AwBx7JA;EACE,mBAAmB;EACnB,aAAa;EACb,WAAW;EACX,uBAAuB;EACvB,mBAAmB;EACnB,UAAU;AxB27JZ;;AwBj8JA;EAQI,eAAe;AxB67JnB;;AyB3mKA;EACE,cvBA4B;EuBC5B,cAAc;EACd,evB6BW;EuB5BX,gBvBmCe;AF2kKjB;;AyBlnKA;EAMI,oBAAoB;AzBgnKxB;;AyBtnKA;EASI,kBvBwBY;AFylKhB;;AyB1nKA;EAWI,kBvBoBY;AF+lKhB;;AyB9nKA;EAaI,iBvBiBW;AFomKf;;AyBnnKA;EACE,cAAc;EACd,kBvBgBc;EuBfd,mBAAmB;AzBsnKrB;;AyBznKA;EAOM,YvBZyB;AFkoK/B;;AyB7nKA;EAOM,cvBzBuB;AFmpK7B;;AyBjoKA;EAOM,iBvBdwB;AF4oK9B;;AyBroKA;EAOM,cvBrBwB;AFupK9B;;AyBzoKA;EAOM,cvBP4B;AF6oKlC;;AyB7oKA;EAOM,cvBL4B;AF+oKlC;;AyBjpKA;EAOM,cvBN4B;AFopKlC;;AyBrpKA;EAOM,cvBR4B;AF0pKlC;;AyBzpKA;EAOM,cvBT4B;AF+pKlC;;AyB7pKA;EAOM,cvBH2B;AF6pKjC;;AyBtpKA;EAEI,sBAAsB;AzBwpK1B;;AyB1pKA;EAKI,aAAa;EACb,2BAA2B;AzBypK/B;;AyB/pKA;EASQ,kBAAkB;AzB0pK1B;;AyBnqKA;;;EAcU,gBAAgB;AzB2pK1B;;AyBzqKA;;;EAmBU,6BAA6B;EAC7B,0BAA0B;AzB4pKpC;;AyBhrKA;;;EAyBU,4BAA4B;EAC5B,yBAAyB;AzB6pKnC;;AyBvrKA;;;;;EAiCY,UAAU;AzB8pKtB;;AyB/rKA;;;;;;;;;EAsCY,UAAU;AzBqqKtB;;AyB3sKA;;;;;;;;;EAwCc,UAAU;AzB+qKxB;;AyBvtKA;EA0CQ,YAAY;EACZ,cAAc;AzBirKtB;;AyB5tKA;EA6CM,uBAAuB;AzBmrK7B;;AyBhuKA;EA+CM,yBAAyB;AzBqrK/B;;AyBpuKA;EAkDQ,YAAY;EACZ,cAAc;AzBsrKtB;;AyBzuKA;EAqDI,aAAa;EACb,2BAA2B;AzBwrK/B;;AyB9uKA;EAwDM,cAAc;AzB0rKpB;;AyBlvKA;EA0DQ,gBAAgB;EAChB,qBAAqB;AzB4rK7B;;AyBvvKA;EA6DQ,YAAY;EACZ,cAAc;AzB8rKtB;;AyB5vKA;EAgEM,uBAAuB;AzBgsK7B;;AyBhwKA;EAkEM,yBAAyB;AzBksK/B;;AyBpwKA;EAoEM,eAAe;AzBosKrB;;AyBxwKA;EAwEU,sBAAsB;AzBosKhC;;AyB5wKA;EA0EQ,uBAAuB;AzBssK/B;;AyBhxKA;EA4EQ,gBAAgB;AzBwsKxB;;AC9tKE;EwBtDF;IA+EM,aAAa;EzB0sKjB;AACF;;AyBzsKA;EAEI,kBAAkB;AzB2sKtB;;AC5uKE;EwB+BF;IAII,qBAAqB;EzB8sKvB;AACF;;AC9uKE;EwB2BF;IAMI,aAAa;IACb,YAAY;IACZ,cAAc;IACd,oBAAoB;IACpB,iBAAiB;EzBktKnB;EyB5tKF;IAYM,kBvBtFU;IuBuFV,oBAAoB;EzBmtKxB;EyBhuKF;IAeM,oBAAoB;EzBotKxB;EyBnuKF;IAiBM,kBvB7FU;IuB8FV,oBAAoB;EzBqtKxB;EyBvuKF;IAoBM,iBvBjGS;IuBkGT,oBAAoB;EzBstKxB;AACF;;AyBrtKA;EAEI,gBAAgB;AzButKpB;;AC3wKE;EwBkDF;IAII,aAAa;IACb,aAAa;IACb,YAAY;IACZ,cAAc;EzB0tKhB;EyBjuKF;IASM,gBAAgB;EzB2tKpB;EyBpuKF;IAWM,cAAc;EzB4tKlB;EyBvuKF;IAaQ,YAAY;EzB6tKlB;EyB1uKF;IAeQ,qBAAqB;EzB8tK3B;AACF;;AyB7tKA;EACE,sBAAsB;EACtB,WAAW;EACX,evBtHW;EuBuHX,kBAAkB;EAClB,gBAAgB;AzBguKlB;;AyBruKA;;;EAaU,cvB9JoB;AF43K9B;;AyB3uKA;;;EAeQ,kBvBjIQ;AFm2KhB;;AyBjvKA;;;EAiBQ,kBvBrIQ;AF22KhB;;AyBvvKA;;;EAmBQ,iBvBxIO;AFk3Kf;;AyB7vKA;EAqBM,cvBnKwB;EuBoKxB,atBzKgB;EsB0KhB,oBAAoB;EACpB,kBAAkB;EAClB,MAAM;EACN,YtB7KgB;EsB8KhB,UAAU;AzB4uKhB;;AyBvwKA;;EA+BM,mBtBlLgB;AH+5KtB;;AyB5wKA;EAiCM,OAAO;AzB+uKb;;AyBhxKA;;EAqCM,oBtBxLgB;AHw6KtB;;AyBrxKA;EAuCM,QAAQ;AzBkvKd;;AyBzxKA;EA2CM,6BAA6B;EAC7B,cAAc;EACd,YAAY;EACZ,UAAU;AzBkvKhB;;AyBhyKA;EAgDM,kBvBlKU;AFs5KhB;;AyBpyKA;EAkDM,kBvBtKU;AF45KhB;;AyBxyKA;EAoDM,iBvBzKS;AFi6Kf;;A0B37KA;EAGE,exByBW;EwBxBX,mBAAmB;A1B47KrB;;A0Bh8KA;EAMI,mBAAmB;EACnB,cxBM8B;EwBL9B,aAAa;EACb,uBAAuB;EACvB,iBAduC;A1B48K3C;;A0Bx8KA;EAYM,cxBfwB;AF+8K9B;;A0B58KA;EAcI,mBAAmB;EACnB,aAAa;A1Bk8KjB;;A0Bj9KA;EAiBM,eAAe;A1Bo8KrB;;A0Br9KA;EAoBQ,cxBvBsB;EwBwBtB,eAAe;EACf,oBAAoB;A1Bq8K5B;;A0B39KA;EAwBM,cxBxBwB;EwByBxB,iBAAiB;A1Bu8KvB;;A0Bh+KA;;EA4BI,uBAAuB;EACvB,aAAa;EACb,eAAe;EACf,2BAA2B;A1By8K/B;;A0Bx+KA;EAkCM,mBAAmB;A1B08KzB;;A0B5+KA;EAoCM,kBAAkB;A1B48KxB;;A0Bh/KA;;EAyCM,uBAAuB;A1B48K7B;;A0Br/KA;;EA6CM,yBAAyB;A1B68K/B;;A0B1/KA;EAgDI,kBxBnBY;AFi+KhB;;A0B9/KA;EAkDI,kBxBvBY;AFu+KhB;;A0BlgLA;EAoDI,iBxB1BW;AF4+Kf;;A0BtgLA;EAwDM,iBAAiB;A1Bk9KvB;;A0B1gLA;EA2DM,iBAAiB;A1Bm9KvB;;A0B9gLA;EA8DM,iBAAiB;A1Bo9KvB;;A0BlhLA;EAiEM,iBAAiB;A1Bq9KvB;;A2B5gLA;EACE,uBzBL6B;EyBM7B,0FzBnB2B;EyBoB3B,czBf4B;EyBgB5B,eAAe;EACf,kBAAkB;A3B+gLpB;;A2B7gLA;EACE,6BAvBwC;EAwBxC,oBAAoB;EACpB,kDzB3B2B;EyB4B3B,aAAa;A3BghLf;;A2B9gLA;EACE,mBAAmB;EACnB,czB5B4B;EyB6B5B,aAAa;EACb,YAAY;EACZ,gBzBOe;EyBNf,qBAhCgC;A3BijLlC;;A2BvhLA;EAQI,uBAAuB;A3BmhL3B;;A2BjhLA;EACE,mBAAmB;EACnB,eAAe;EACf,aAAa;EACb,uBAAuB;EACvB,qBAzCgC;A3B6jLlC;;A2BlhLA;EACE,cAAc;EACd,kBAAkB;A3BqhLpB;;A2BnhLA;EACE,6BA5CyC;EA6CzC,eA5C2B;A3BkkL7B;;A2BphLA;EACE,6BA7CwC;EA8CxC,6BzBhD6B;EyBiD7B,oBAAoB;EACpB,aAAa;A3BuhLf;;A2BrhLA;EACE,mBAAmB;EACnB,aAAa;EACb,aAAa;EACb,YAAY;EACZ,cAAc;EACd,uBAAuB;EACvB,gBAvD2B;A3B+kL7B;;A2B/hLA;EASI,+BzB7D2B;AFulL/B;;A2BthLA;EAEI,qBzB9BkB;AFsjLtB;;A4BnlLA;EACE,oBAAoB;EACpB,kBAAkB;EAClB,mBAAmB;A5BslLrB;;A4BzlLA;EAOM,cAAc;A5BslLpB;;A4B7lLA;EAUM,UAAU;EACV,QAAQ;A5BulLd;;A4BlmLA;EAcM,YAAY;EACZ,mBA9BuB;EA+BvB,oBAAoB;EACpB,SAAS;A5BwlLf;;A4BtlLA;EACE,aAAa;EACb,OAAO;EACP,gBAzC6B;EA0C7B,gBAtC2B;EAuC3B,kBAAkB;EAClB,SAAS;EACT,WApCqB;A5B6nLvB;;A4BvlLA;EACE,uB1BjC6B;E0BkC7B,kB1BoBU;E0BnBV,0F1BhD2B;E0BiD3B,sBA9CsC;EA+CtC,mBA9CmC;A5BwoLrC;;AcnoLgB;Ec4Cd,c1BhD4B;E0BiD5B,cAAc;EACd,mBAAmB;EACnB,gBAAgB;EAChB,sBAAsB;EACtB,kBAAkB;A5B2lLpB;;A4BzlLA;;EAEE,mBAAmB;EACnB,gBAAgB;EAChB,mBAAmB;EACnB,WAAW;A5B4lLb;;A4BjmLA;;EAOI,4B1BxD0B;E0ByD1B,c1BpEyB;AFmqL7B;;A4BvmLA;;EAUI,yB1BlD8B;E0BmD9B,WnBSY;ATylLhB;;A4BhmLA;EACE,yB1BjE6B;E0BkE7B,YAAY;EACZ,cAAc;EACd,WAAW;EACX,gBAAgB;A5BmmLlB;;A6BjrLA;EAEE,mBAAmB;EACnB,8BAA8B;A7BmrLhC;;A6BtrLA;EAKI,kB3B8DQ;AFunLZ;;A6B1rLA;EAOI,qBAAqB;EACrB,mBAAmB;A7BurLvB;;A6B/rLA;EAWI,aAAa;A7BwrLjB;;A6BnsLA;;EAcM,aAAa;A7B0rLnB;;A6BxsLA;EAgBM,aAAa;A7B4rLnB;;A6B5sLA;EAmBQ,gBAAgB;EAChB,qBAtBiC;A7BmtLzC;;A6BjtLA;EAsBQ,YAAY;A7B+rLpB;;ACloLE;E4BnFF;IAyBI,aAAa;E7BisLf;E6B1tLF;IA4BQ,YAAY;E7BisLlB;AACF;;A6BhsLA;EACE,mBAAmB;EACnB,aAAa;EACb,gBAAgB;EAChB,YAAY;EACZ,cAAc;EACd,uBAAuB;A7BmsLzB;;A6BzsLA;;EASI,gBAAgB;A7BqsLpB;;AC7pLE;E4BjDF;IAaM,sBA7CmC;E7BmvLvC;AACF;;A6BrsLA;;EAEE,gBAAgB;EAChB,YAAY;EACZ,cAAc;A7BwsLhB;;A6B5sLA;;EAQM,YAAY;A7BysLlB;;AC3qLE;E4BtCF;;IAYQ,qBA3DiC;E7BswLvC;AACF;;A6B1sLA;EACE,mBAAmB;EACnB,2BAA2B;A7B6sL7B;;AC3rLE;E4BpBF;IAMM,kBAAkB;E7B8sLtB;AACF;;AC7rLE;E4BxBF;IAQI,aAAa;E7BktLf;AACF;;A6BjtLA;EACE,mBAAmB;EACnB,yBAAyB;A7BotL3B;;ACxsLE;E4BdF;IAKI,aAAa;E7BstLf;AACF;;A8BzxLA;EAEE,uB5BG6B;E4BF7B,kB5BwDU;E4BvDV,4E5BZ2B;AFuyL7B;;A8BtxLA;EACE,cAAc;EACd,kBAAkB;A9ByxLpB;;A8B3xLA;EAII,c5BhB0B;AF2yL9B;;A8B/xLA;EAMI,2B5B4CQ;E4B3CR,4B5B2CQ;AFkvLZ;;A8BpyLA;EASI,8B5ByCQ;E4BxCR,+B5BwCQ;AFuvLZ;;A8BzyLA;EAYI,gC5BrB0B;AFszL9B;;A8B7yLA;EAcI,yB5BX8B;E4BY9B,WrBgDY;ATmvLhB;;A8BjyLA;EACE,4B5BxB4B;E4ByB5B,eAAe;A9BoyLjB;;A+Bx0LA;EACE,uBAAuB;EACvB,aAAa;EACb,gBAAgB;A/B20LlB;;A+B90LA;EAKI,sBAAsB;A/B60L1B;;A+Bl1LA;EAOI,8C7BC0B;E6BA1B,aAAa;EACb,oBAAoB;A/B+0LxB;;A+Bx1LA;;EAYM,qBAAqB;A/Bi1L3B;;A+B71LA;EAcM,mBAAmB;A/Bm1LzB;;A+Bj2LA;EAgBQ,kBAAkB;A/Bq1L1B;;A+Br2LA;EAkBI,8C7BV0B;E6BW1B,gBAAgB;EAChB,iBAAiB;A/Bu1LrB;;A+B32LA;EAwBM,kBAAkB;EAClB,mBAAmB;A/Bu1LzB;;A+Br1LA;;EAEE,gBAAgB;EAChB,YAAY;EACZ,cAAc;A/Bw1LhB;;A+Bt1LA;EACE,kBAAkB;A/By1LpB;;A+Bv1LA;EACE,iBAAiB;A/B01LnB;;A+Bx1LA;EACE,gBAAgB;EAChB,YAAY;EACZ,cAAc;EACd,gBAAgB;A/B21LlB;;ACvzLE;E8BxCF;IAQI,gBAAgB;E/B41LlB;AACF;;AgC53LA;EACE,e9BkBW;AF62Lb;;AgCh4LA;EAII,kB9BgBY;AFg3LhB;;AgCp4LA;EAMI,kB9BYY;AFs3LhB;;AgCx4LA;EAQI,iB9BSW;AF23Lf;;AgCl4LA;EACE,iBArB0B;AhC05L5B;;AgCt4LA;EAGI,kB9BqCc;E8BpCd,c9BzB0B;E8B0B1B,cAAc;EACd,qBAzBiC;AhCg6LrC;;AgC74LA;EAQM,4B9BvBwB;E8BwBxB,c9B/BwB;AFw6L9B;;AgCl5LA;EAYM,yB9BlB4B;E8BmB5B,WvByCU;ATi2LhB;;AgCv5LA;EAgBM,8B9BlCwB;E8BmCxB,cAnC0B;EAoC1B,oBAnCgC;AhC86LtC;;AgCz4LA;EACE,c9BzC4B;E8B0C5B,iBApC2B;EAqC3B,qBApC+B;EAqC/B,yBAAyB;AhC44L3B;;AgCh5LA;EAMI,eAtCoB;AhCo7LxB;;AgCp5LA;EAQI,kBAxCoB;AhCw7LxB;;AiCn7LA;EAEE,4B/BV4B;E+BW5B,kB/B6CU;E+B5CV,e/BYW;AFy6Lb;;AiCz7LA;EAMI,mBAAmB;AjCu7LvB;;AiC77LA;EAQI,mBAAmB;EACnB,0BAA0B;AjCy7L9B;;AiCl8LA;EAYI,kB/BKY;AFq7LhB;;AiCt8LA;EAcI,kB/BCY;AF27LhB;;AiC18LA;EAgBI,iB/BFW;AFg8Lf;;AiC98LA;EAsCM,uBAH+C;AjC+6LrD;;AiCl9LA;EAwCQ,uB/B9CuB;E+B+CvB,c/B5DqB;AF0+L7B;;AiCv9LA;EA2CQ,mB/BjDuB;AFi+L/B;;AiC39LA;EAsCM,yBAH+C;AjC47LrD;;AiC/9LA;EAwCQ,yB/B3DqB;E+B4DrB,Y/B/CuB;AF0+L/B;;AiCp+LA;EA2CQ,qB/B9DqB;AF2/L7B;;AiCx+LA;EAsCM,yBAH+C;AjCy8LrD;;AiC5+LA;EAwCQ,4B/BhDsB;E+BiDtB,yBxBkBa;ATs7LrB;;AiCj/LA;EA2CQ,wB/BnDsB;AF6/L9B;;AiCr/LA;EAsCM,yBAH+C;AjCs9LrD;;AiCz/LA;EAwCQ,yB/BvDsB;E+BwDtB,WxBoBQ;ATi8LhB;;AiC9/LA;EA2CQ,qB/B1DsB;AFihM9B;;AiClgMA;EAsCM,yBxB8B0C;ATk8LhD;;AiCtgMA;EAwCQ,yB/BzC0B;E+B0C1B,WxBoBQ;AT88LhB;;AiC3gMA;EA2CQ,qB/B5C0B;E+B6C1B,cxBiC6D;ATm8LrE;;AiChhMA;EAsCM,yBxB8B0C;ATg9LhD;;AiCphMA;EAwCQ,yB/BvC0B;E+BwC1B,WxBoBQ;AT49LhB;;AiCzhMA;EA2CQ,qB/B1C0B;E+B2C1B,cxBiC6D;ATi9LrE;;AiC9hMA;EAsCM,yBxB8B0C;AT89LhD;;AiCliMA;EAwCQ,yB/BxC0B;E+ByC1B,WxBoBQ;AT0+LhB;;AiCviMA;EA2CQ,qB/B3C0B;E+B4C1B,cxBiC6D;AT+9LrE;;AiC5iMA;EAsCM,yBxB8B0C;AT4+LhD;;AiChjMA;EAwCQ,yB/B1C0B;E+B2C1B,WxBoBQ;ATw/LhB;;AiCrjMA;EA2CQ,qB/B7C0B;E+B8C1B,cxBiC6D;AT6+LrE;;AiC1jMA;EAsCM,yBxB8B0C;AT0/LhD;;AiC9jMA;EAwCQ,yB/B3C0B;E+B4C1B,yBxBkBa;ATwgMrB;;AiCnkMA;EA2CQ,qB/B9C0B;E+B+C1B,cxBiC6D;AT2/LrE;;AiCxkMA;EAsCM,yBxB8B0C;ATwgMhD;;AiC5kMA;EAwCQ,yB/BrCyB;E+BsCzB,WxBoBQ;ATohMhB;;AiCjlMA;EA2CQ,qB/BxCyB;E+ByCzB,cxBiC6D;ATygMrE;;AiCxiMA;EACE,mBAAmB;EACnB,yB/B9D4B;E+B+D5B,0BAAgE;EAChE,WxBWc;EwBVd,aAAa;EACb,gB/B7Be;E+B8Bf,8BAA8B;EAC9B,iBAAiB;EACjB,mBAtEiC;EAuEjC,kBAAkB;AjC2iMpB;;AiCrjMA;EAYI,YAAY;EACZ,cAAc;EACd,mBAAmB;AjC6iMvB;;AiC3jMA;EAgBI,eAjEgC;EAkEhC,yBAAyB;EACzB,0BAA0B;AjC+iM9B;;AiC7iMA;EACE,qB/B9E4B;E+B+E5B,kB/BpBU;E+BqBV,mBAAmB;EACnB,uBAjFmC;EAkFnC,c/BrF4B;E+BsF5B,qBAjFiC;AjCioMnC;;AiCtjMA;;EASI,uB/BjF2B;AFmoM/B;;AiC3jMA;EAWI,6BAlFgD;AjCsoMpD;;AkCxnMA;EAEE,mBAAmB;EACnB,aAAa;EACb,sBAAsB;EACtB,uBAAuB;EACvB,gBAAgB;EAChB,eAAe;EACf,WAtCU;AlCgqMZ;;AkCloMA;EAWI,aAAa;AlC2nMjB;;AkCznMA;EAEE,wChC3C2B;AFsqM7B;;AkCznMA;;EAEE,cA5CgC;EA6ChC,+BAA0D;EAC1D,cAAc;EACd,kBAAkB;EAClB,WAAW;AlC4nMb;;AC5lME;EiCtCF;;IASI,cAAc;IACd,8BAA0D;IAC1D,YAtDuB;ElCqrMzB;AACF;;AkC9nMA;EAEE,gBAAgB;EAChB,YAtD2B;EAuD3B,eAAe;EACf,WAvDsB;EAwDtB,SAvDoB;EAwDpB,WA1D2B;AlC0rM7B;;AkC9nMA;EACE,aAAa;EACb,sBAAsB;EACtB,8BAAgD;EAChD,gBAAgB;EAChB,uBAAuB;AlCioMzB;;AkC/nMA;;EAEE,mBAAmB;EACnB,4BhClE4B;EgCmE5B,aAAa;EACb,cAAc;EACd,2BAA2B;EAC3B,aAlE4B;EAmE5B,kBAAkB;AlCkoMpB;;AkChoMA;EACE,gChC7E4B;EgC8E5B,2BhClBgB;EgCmBhB,4BhCnBgB;AFspMlB;;AkCjoMA;EACE,chCtF4B;EgCuF5B,YAAY;EACZ,cAAc;EACd,iBhC5Da;EgC6Db,cA3E8B;AlC+sMhC;;AkCloMA;EACE,8BhC7BgB;EgC8BhB,+BhC9BgB;EgC+BhB,6BhC3F4B;AFguM9B;;AkCxoMA;EAMM,mBAAmB;AlCsoMzB;;AkCpoMA;EjC5CE,iCAAiC;EiC8CjC,uBhC7F6B;EgC8F7B,YAAY;EACZ,cAAc;EACd,cAAc;EACd,aApF4B;AlC2tM9B;;AmCjsMA;EACE,uBjCxC6B;EiCyC7B,mBArDqB;EAsDrB,kBAAkB;EAClB,WApDW;AnCwvMb;;AmCxsMA;EASM,uBjChDyB;EiCiDzB,cjC9DuB;AFiwM7B;;AmC7sMA;;EAcU,cjClEmB;AFswM7B;;AmCltMA;;;;EAoBY,yB1BmCqB;E0BlCrB,cjCzEiB;AF8wM7B;;AmC1tMA;EAwBY,qBjC5EiB;AFkxM7B;;AmC9tMA;EA0BQ,cjC9EqB;AFsxM7B;;AC/sME;EkCnBF;;;;IAgCY,cjCpFiB;EF8xM3B;EmC1uMF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,cjC3Fe;EF2yM3B;EmCvvMF;;IA0Cc,qBjC9Fe;EF+yM3B;EmC3vMF;;;IA8CU,yB1BSuB;I0BRvB,cjCnGmB;EFqzM3B;EmCjwMF;IAmDc,uBjC1FiB;IiC2FjB,cjCxGe;EFyzM3B;AACF;;AmCtwMA;EASM,yBjC7DuB;EiC8DvB,YjCjDyB;AFkzM/B;;AmC3wMA;;EAcU,YjCrDqB;AFuzM/B;;AmChxMA;;;;EAoBY,uB1BmCqB;E0BlCrB,YjC5DmB;AF+zM/B;;AmCxxMA;EAwBY,mBjC/DmB;AFm0M/B;;AmC5xMA;EA0BQ,YjCjEuB;AFu0M/B;;AC7wME;EkCnBF;;;;IAgCY,YjCvEmB;EF+0M7B;EmCxyMF;;;;;;;;;;IAsCc,uB1BiBmB;I0BhBnB,YjC9EiB;EF41M7B;EmCrzMF;;IA0Cc,mBjCjFiB;EFg2M7B;EmCzzMF;;;IA8CU,uB1BSuB;I0BRvB,YjCtFqB;EFs2M7B;EmC/zMF;IAmDc,yBjCvGe;IiCwGf,YjC3FiB;EF02M7B;AACF;;AmCp0MA;EASM,4BjClDwB;EiCmDxB,yB1BgBe;AT+yMrB;;AmCz0MA;;EAcU,yB1BYW;ATozMrB;;AmC90MA;;;;EAoBY,yB1BmCqB;E0BlCrB,yB1BKS;AT4zMrB;;AmCt1MA;EAwBY,gC1BES;ATg0MrB;;AmC11MA;EA0BQ,yB1BAa;ATo0MrB;;AC30ME;EkCnBF;;;;IAgCY,yB1BNS;ET40MnB;EmCt2MF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,yB1BbO;ETy1MnB;EmCn3MF;;IA0Cc,gC1BhBO;ET61MnB;EmCv3MF;;;IA8CU,yB1BSuB;I0BRvB,yB1BrBW;ETm2MnB;EmC73MF;IAmDc,4BjC5FgB;IiC6FhB,yB1B1BO;ETu2MnB;AACF;;AmCl4MA;EASM,yBjCzDwB;EiC0DxB,W1BkBU;AT22MhB;;AmCv4MA;;EAcU,W1BcM;ATg3MhB;;AmC54MA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;ATw3MhB;;AmCp5MA;EAwBY,kB1BII;AT43MhB;;AmCx5MA;EA0BQ,W1BEQ;ATg4MhB;;ACz4ME;EkCnBF;;;;IAgCY,W1BJI;ETw4Md;EmCp6MF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ETq5Md;EmCj7MF;;IA0Cc,kB1BdE;ETy5Md;EmCr7MF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ET+5Md;EmC37MF;IAmDc,yBjCnGgB;IiCoGhB,W1BxBE;ETm6Md;AACF;;AmCh8MA;EASM,yBjC3C4B;EiC4C5B,W1BkBU;ATy6MhB;;AmCr8MA;;EAcU,W1BcM;AT86MhB;;AmC18MA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;ATs7MhB;;AmCl9MA;EAwBY,kB1BII;AT07MhB;;AmCt9MA;EA0BQ,W1BEQ;AT87MhB;;ACv8ME;EkCnBF;;;;IAgCY,W1BJI;ETs8Md;EmCl+MF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ETm9Md;EmC/+MF;;IA0Cc,kB1BdE;ETu9Md;EmCn/MF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ET69Md;EmCz/MF;IAmDc,yBjCrFoB;IiCsFpB,W1BxBE;ETi+Md;AACF;;AmC9/MA;EASM,yBjCzC4B;EiC0C5B,W1BkBU;ATu+MhB;;AmCngNA;;EAcU,W1BcM;AT4+MhB;;AmCxgNA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;ATo/MhB;;AmChhNA;EAwBY,kB1BII;ATw/MhB;;AmCphNA;EA0BQ,W1BEQ;AT4/MhB;;ACrgNE;EkCnBF;;;;IAgCY,W1BJI;ETogNd;EmChiNF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ETihNd;EmC7iNF;;IA0Cc,kB1BdE;ETqhNd;EmCjjNF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ET2hNd;EmCvjNF;IAmDc,yBjCnFoB;IiCoFpB,W1BxBE;ET+hNd;AACF;;AmC5jNA;EASM,yBjC1C4B;EiC2C5B,W1BkBU;ATqiNhB;;AmCjkNA;;EAcU,W1BcM;AT0iNhB;;AmCtkNA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;ATkjNhB;;AmC9kNA;EAwBY,kB1BII;ATsjNhB;;AmCllNA;EA0BQ,W1BEQ;AT0jNhB;;ACnkNE;EkCnBF;;;;IAgCY,W1BJI;ETkkNd;EmC9lNF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ET+kNd;EmC3mNF;;IA0Cc,kB1BdE;ETmlNd;EmC/mNF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ETylNd;EmCrnNF;IAmDc,yBjCpFoB;IiCqFpB,W1BxBE;ET6lNd;AACF;;AmC1nNA;EASM,yBjC5C4B;EiC6C5B,W1BkBU;ATmmNhB;;AmC/nNA;;EAcU,W1BcM;ATwmNhB;;AmCpoNA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;ATgnNhB;;AmC5oNA;EAwBY,kB1BII;ATonNhB;;AmChpNA;EA0BQ,W1BEQ;ATwnNhB;;ACjoNE;EkCnBF;;;;IAgCY,W1BJI;ETgoNd;EmC5pNF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ET6oNd;EmCzqNF;;IA0Cc,kB1BdE;ETipNd;EmC7qNF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ETupNd;EmCnrNF;IAmDc,yBjCtFoB;IiCuFpB,W1BxBE;ET2pNd;AACF;;AmCxrNA;EASM,yBjC7C4B;EiC8C5B,yB1BgBe;ATmqNrB;;AmC7rNA;;EAcU,yB1BYW;ATwqNrB;;AmClsNA;;;;EAoBY,yB1BmCqB;E0BlCrB,yB1BKS;ATgrNrB;;AmC1sNA;EAwBY,gC1BES;ATorNrB;;AmC9sNA;EA0BQ,yB1BAa;ATwrNrB;;AC/rNE;EkCnBF;;;;IAgCY,yB1BNS;ETgsNnB;EmC1tNF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,yB1BbO;ET6sNnB;EmCvuNF;;IA0Cc,gC1BhBO;ETitNnB;EmC3uNF;;;IA8CU,yB1BSuB;I0BRvB,yB1BrBW;ETutNnB;EmCjvNF;IAmDc,yBjCvFoB;IiCwFpB,yB1B1BO;ET2tNnB;AACF;;AmCtvNA;EASM,yBjCvC2B;EiCwC3B,W1BkBU;AT+tNhB;;AmC3vNA;;EAcU,W1BcM;ATouNhB;;AmChwNA;;;;EAoBY,yB1BmCqB;E0BlCrB,W1BOI;AT4uNhB;;AmCxwNA;EAwBY,kB1BII;ATgvNhB;;AmC5wNA;EA0BQ,W1BEQ;ATovNhB;;AC7vNE;EkCnBF;;;;IAgCY,W1BJI;ET4vNd;EmCxxNF;;;;;;;;;;IAsCc,yB1BiBmB;I0BhBnB,W1BXE;ETywNd;EmCryNF;;IA0Cc,kB1BdE;ET6wNd;EmCzyNF;;;IA8CU,yB1BSuB;I0BRvB,W1BnBM;ETmxNd;EmC/yNF;IAmDc,yBjCjFmB;IiCkFnB,W1BxBE;ETuxNd;AACF;;AmCpzNA;EAsDI,oBAAoB;EACpB,aAAa;EACb,mBA3GmB;EA4GnB,WAAW;AnCkwNf;;AmC3zNA;EA2DI,gCjCpG0B;AFw2N9B;;AmC/zNA;EALE,OAAO;EACP,eAAe;EACf,QAAQ;EACR,WA7CiB;AnCq3NnB;;AmCt0NA;EAgEI,SAAS;AnC0wNb;;AmC10NA;EAkEM,iCjC3GwB;AFu3N9B;;AmC90NA;EAoEI,MAAM;AnC8wNV;;AmC5wNA;;EAGI,oBA5HmB;AnC04NvB;;AmCjxNA;;EAKI,uBA9HmB;AnC+4NvB;;AmC/wNA;;EAEE,oBAAoB;EACpB,aAAa;EACb,cAAc;EACd,mBArIqB;AnCu5NvB;;AmChxNA;EAIM,6BAA6B;AnCgxNnC;;AmC9wNA;ElClFE,iCAAiC;EkCoFjC,gBAAgB;EAChB,gBAAgB;EAChB,kBAAkB;AnCixNpB;;AmC/wNA;EACE,cjChJ4B;EDoB5B,eAAe;EACf,cAAc;EACd,ekC1BqB;ElC2BrB,kBAAkB;EAClB,ckC5BqB;EAsJrB,iBAAiB;AnCsxNnB;;AC/4NE;EACE,8BAA8B;EAC9B,cAAc;EACd,WAAW;EACX,qBAAqB;EACrB,kBAAkB;EAClB,wBAAwB;EACxB,yBCiCQ;EDhCR,yDAAyD;EACzD,oCC0Ba;EDzBb,WAAW;ADk5Nf;;ACj5NI;EACE,oBAAoB;ADo5N1B;;ACn5NI;EACE,oBAAoB;ADs5N1B;;ACr5NI;EACE,oBAAoB;ADw5N1B;;ACv5NE;EACE,qCAAiC;AD05NrC;;ACt5NM;EACE,wCAAwC;ADy5NhD;;ACx5NM;EACE,UAAU;AD25NlB;;AC15NM;EACE,0CAA0C;AD65NlD;;AmC7zNA;EACE,aAAa;AnCg0Nf;;AmC9zNA;;EAEE,cjCzJ4B;EiC0J5B,cAAc;EACd,gBAAgB;EAChB,uBAAuB;EACvB,kBAAkB;AnCi0NpB;;AmCv0NA;;EASM,qBAAqB;EACrB,sBAAsB;AnCm0N5B;;AmCj0NA;;EAEE,eAAe;AnCo0NjB;;AmCt0NA;;;;;EAOI,yBjCnK0B;EiCoK1B,cjC5J8B;AFm+NlC;;AmCr0NA;EACE,YAAY;EACZ,cAAc;AnCw0NhB;;AmC10NA;EAII,mBA1KgC;AnCo/NpC;;AmC90NA;EAMI,UAAU;AnC40Nd;;AmCl1NA;EAQI,YAAY;EACZ,cAAc;AnC80NlB;;AmCv1NA;EAWI,oCAAoC;EACpC,mBA7LmB;EA8LnB,kCAAkC;AnCg1NtC;;AmC71NA;EAgBM,6BAlLyC;EAmLzC,4BjC/K4B;AFggOlC;;AmCl2NA;EAmBM,6BAlL0C;EAmL1C,4BjClL4B;EiCmL5B,0BAlLuC;EAmLvC,wBAlLqC;EAmLrC,cjCrL4B;EiCsL5B,kCAAwE;AnCm1N9E;;AmCj1NA;EACE,YAAY;EACZ,cAAc;AnCo1NhB;;AmCl1NA;EACE,oBAAoB;AnCq1NtB;;AmCt1NA;EAII,qBjChM8B;EiCiM9B,oBAAoB;EACpB,cAAc;AnCs1NlB;;AmCp1NA;EACE,mBAAmB;EACnB,sBAAsB;EACtB,mBAAmB;AnCu1NrB;;AmC11NA;EAKI,oBAAoB;EACpB,qBAAqB;AnCy1NzB;;AmCv1NA;EACE,4BjCtN4B;EiCuN5B,YAAY;EACZ,aAAa;EACb,WA5LyB;EA6LzB,gBAAgB;AnC01NlB;;ACp/NE;EkCvBF;IAqLI,cAAc;EnC21NhB;EmC11NA;;IAGI,mBAAmB;IACnB,aAAa;EnC21NjB;EmC11NA;IAEI,aAAa;EnC21NjB;EmCn7NF;IA0FI,uBjCtO2B;IiCuO3B,4CjCpPyB;IiCqPzB,iBAAiB;EnC41NnB;EmC/1NA;IAKI,cAAc;EnC61NlB;EmC31NA;IA1MA,OAAO;IACP,eAAe;IACf,QAAQ;IACR,WA7CiB;EnCqlOjB;EmCj2NA;IAKI,SAAS;EnC+1Nb;EmCp2NA;IAOM,4CjChQqB;EFgmO3B;EmCv2NA;IASI,MAAM;EnCi2NV;EmC12NA;IlC7LA,iCAAiC;IkC2M3B,iCAA2C;IAC3C,cAAc;EnCg2NpB;EmC/1NA;;IAGI,oBA3QiB;EnC2mOrB;EmCn2NA;;IAKI,uBA7QiB;EnC+mOrB;AACF;;AC1iOE;EkC0MA;;;;IAIE,oBAAoB;IACpB,aAAa;EnCo2Nf;EmCtkOF;IAoOI,mBAvRmB;EnC4nOrB;EmCt2NA;IAGI,kBAvR0B;EnC6nO9B;EmCz2NA;;IAMM,mBAAmB;EnCu2NzB;EmC72NA;;IASM,kBjC7NI;EFqkOV;EmCj3NA;;;;IAgBQ,wCAAwC;EnCu2NhD;EmCv3NA;IAuBU,wCAAwC;EnCm2NlD;EmC13NA;IA4BU,4BjCxSkB;IiCySlB,cjCpTiB;EFqpO3B;EmC93NA;IA+BU,4BjC3SkB;IiC4SlB,cjCnSsB;EFqoOhC;EmCrgOF;IAqKI,aAAa;EnCm2Nf;EmChgOF;;IAgKI,mBAAmB;IACnB,aAAa;EnCo2Nf;EmC/+NF;IA8IM,oBAAoB;EnCo2NxB;EmCt2NA;IAKM,oDAAoD;EnCo2N1D;EmCz2NA;IAOM,gCjC7TsB;IiC8TtB,0BAAkE;IAClE,gBAAgB;IAChB,YAAY;IACZ,4CjCzUqB;IiC0UrB,SAAS;EnCq2Nf;EmCj3NA;IAkBM,cAAc;EnCk2NpB;EmCj2NM;IAEE,UAAU;IACV,oBAAoB;IACpB,wBAAwB;EnCk2NhC;EmC9hOF;IA8LI,YAAY;IACZ,cAAc;EnCm2NhB;EmCl2NA;IACE,2BAA2B;IAC3B,kBAAkB;EnCo2NpB;EmCn2NA;IACE,yBAAyB;IACzB,iBAAiB;EnCq2NnB;EmC3+NF;IAwII,uBjCnV2B;IiCoV3B,8BjC7Rc;IiC8Rd,+BjC9Rc;IiC+Rd,6BjC3V0B;IiC4V1B,2CjCpWyB;IiCqWzB,aAAa;IACb,mBAAmB;IACnB,OAAO;IACP,eAAe;IACf,kBAAkB;IAClB,SAAS;IACT,WA9UkB;EnCorOpB;EmCz/NF;IAqJM,sBAAsB;IACtB,mBAAmB;EnCu2NvB;EmCt3NA;IAiBI,mBAAmB;EnCw2NvB;EmCz3NA;IAoBM,4BjCxWsB;IiCyWtB,cjCpXqB;EF4tO3B;EmC73NA;IAuBM,4BjC3WsB;IiC4WtB,cjCnW0B;EF4sOhC;EmCx2NE;IAEE,kBjCtTY;IiCuTZ,gBAAgB;IAChB,4EjC5XuB;IiC6XvB,cAAc;IACd,UAAU;IACV,oBAAoB;IACpB,wBAA8C;IAC9C,2BAA2B;IAC3B,yBjC5TM;IiC6TN,uCAAuC;EnCy2N3C;EmC74NA;IAsCI,UAAU;IACV,QAAQ;EnC02NZ;EmChhOF;IAwKI,cAAc;EnC22NhB;EmC12NA;;IAGI,oBAAoB;EnC22NxB;EmC92NA;;IAKI,qBAAqB;EnC62NzB;EmC32NA;IAjWA,OAAO;IACP,eAAe;IACf,QAAQ;IACR,WA7CiB;EnC4vOjB;EmCj3NA;IAKI,SAAS;EnC+2Nb;EmCp3NA;IAOM,4CjCvZqB;EFuwO3B;EmCv3NA;IASI,MAAM;EnCi3NV;EmCh3NA;;IAGI,oBA5ZiB;EnC6wOrB;EmCp3NA;;IAKI,uBA9ZiB;EnCixOrB;EmCx3NA;;IAOI,oBAA4D;EnCq3NhE;EmC53NA;;IASI,uBAA+D;EnCu3NnE;EmCr3NA;;IAGI,cjCxauB;EF8xO3B;EmCz3NA;;IAKI,6BA/Z2C;EnCuxO/C;EmCv3NA;IAKM,yBjCpasB;EFyxO5B;AACF;;AmCl3NA;EAEI,iCAA2C;AnCo3N/C;;AoC7wOA;EAEE,elCIW;EkCHX,gBAhC0B;ApC+yO5B;;AoClxOA;EAMI,kBlCCY;AF+wOhB;;AoCtxOA;EAQI,kBlCHY;AFqxOhB;;AoC1xOA;EAUI,iBlCNW;AF0xOf;;AoC9xOA;;EAcM,iBAAiB;EACjB,kBAAkB;EAClB,uBlCwBmB;AF6vOzB;;AoCryOA;EAkBM,uBlCsBmB;AFiwOzB;;AoCrxOA;;EAEE,mBAAmB;EACnB,aAAa;EACb,uBAAuB;EACvB,kBAAkB;ApCwxOpB;;AoCtxOA;;;;EAME,cA3D6B;EA4D7B,uBAAuB;EACvB,eA5D8B;EA6D9B,mBA5DkC;EA6DlC,oBA5DmC;EA6DnC,kBAAkB;ApCuxOpB;;AoCrxOA;;;EAGE,qBlChE4B;EkCiE5B,clCrE4B;EkCsE5B,gBjCvEoB;AH+1OtB;;AoC7xOA;;;EAOI,qBlCrE0B;EkCsE1B,clCzE0B;AFq2O9B;;AoCpyOA;;;EAUI,qBlC3D8B;AF21OlC;;AoC1yOA;;;EAYI,iDlCjFyB;AFq3O7B;;AoChzOA;;;EAcI,yBlC3E0B;EkC4E1B,qBlC5E0B;EkC6E1B,gBAAgB;EAChB,clChF0B;EkCiF1B,YAAY;ApCwyOhB;;AoCtyOA;;EAEE,oBAAoB;EACpB,qBAAqB;EACrB,mBAAmB;ApCyyOrB;;AoCvyOA;EAEI,yBlC7E8B;EkC8E9B,qBlC9E8B;EkC+E9B,W3BnBY;AT4zOhB;;AoCvyOA;EACE,clC/F4B;EkCgG5B,oBAAoB;ApC0yOtB;;AoCxyOA;EACE,eAAe;ApC2yOjB;;ACt0OE;EmClDF;IAiFI,eAAe;EpC4yOjB;EoCj0OF;;IAwBI,YAAY;IACZ,cAAc;EpC6yOhB;EoC5yOA;IAEI,YAAY;IACZ,cAAc;EpC6yOlB;AACF;;ACj1OE;EmCsBF;IAiBI,YAAY;IACZ,cAAc;IACd,2BAA2B;IAC3B,QAAQ;EpC+yOV;EoC9yOA;IACE,QAAQ;EpCgzOV;EoC/yOA;IACE,QAAQ;EpCizOV;EoCr5OF;IAsGI,8BAA8B;EpCkzOhC;EoCnzOA;IAIM,QAAQ;EpCkzOd;EoCtzOA;IAMM,uBAAuB;IACvB,QAAQ;EpCmzOd;EoC1zOA;IASM,QAAQ;EpCozOd;EoC7zOA;IAYM,QAAQ;EpCozOd;EoCh0OA;IAcM,QAAQ;EpCqzOd;EoCn0OA;IAgBM,yBAAyB;IACzB,QAAQ;EpCszOd;AACF;;AqC96OA;EACE,kBnCuCgB;EmCtChB,0FnC9B2B;EmC+B3B,enCIW;AF66Ob;;AqCp7OA;EAKI,qBnCakB;AFs6OtB;;AqCx7OA;EAYQ,uBnC3BuB;EmC4BvB,cnCzCqB;AFy9O7B;;AqC77OA;EAeQ,0BnC9BuB;AFg9O/B;;AqCj8OA;EAiBQ,YnChCuB;AFo9O/B;;AqCr8OA;EAYQ,yBnCxCqB;EmCyCrB,YnC5BuB;AFy9O/B;;AqC18OA;EAeQ,4BnC3CqB;AF0+O7B;;AqC98OA;EAiBQ,cnC7CqB;AF8+O7B;;AqCl9OA;EAYQ,4BnC7BsB;EmC8BtB,yB5BqCa;ATq6OrB;;AqCv9OA;EAeQ,+BnChCsB;AF4+O9B;;AqC39OA;EAiBQ,iBnClCsB;AFg/O9B;;AqC/9OA;EAYQ,yBnCpCsB;EmCqCtB,W5BuCQ;ATg7OhB;;AqCp+OA;EAeQ,4BnCvCsB;AFggP9B;;AqCx+OA;EAiBQ,cnCzCsB;AFogP9B;;AqC5+OA;EAYQ,yBnCtB0B;EmCuB1B,W5BuCQ;AT67OhB;;AqCj/OA;EAeQ,4BnCzB0B;AF+/OlC;;AqCr/OA;EAiBQ,cnC3B0B;AFmgPlC;;AqCz/OA;EAYQ,yBnCpB0B;EmCqB1B,W5BuCQ;AT08OhB;;AqC9/OA;EAeQ,4BnCvB0B;AF0gPlC;;AqClgPA;EAiBQ,cnCzB0B;AF8gPlC;;AqCtgPA;EAYQ,yBnCrB0B;EmCsB1B,W5BuCQ;ATu9OhB;;AqC3gPA;EAeQ,4BnCxB0B;AFwhPlC;;AqC/gPA;EAiBQ,cnC1B0B;AF4hPlC;;AqCnhPA;EAYQ,yBnCvB0B;EmCwB1B,W5BuCQ;ATo+OhB;;AqCxhPA;EAeQ,4BnC1B0B;AFuiPlC;;AqC5hPA;EAiBQ,cnC5B0B;AF2iPlC;;AqChiPA;EAYQ,yBnCxB0B;EmCyB1B,yB5BqCa;ATm/OrB;;AqCriPA;EAeQ,4BnC3B0B;AFqjPlC;;AqCziPA;EAiBQ,cnC7B0B;AFyjPlC;;AqC7iPA;EAYQ,yBnClByB;EmCmBzB,W5BuCQ;AT8/OhB;;AqCljPA;EAeQ,4BnCrByB;AF4jPjC;;AqCtjPA;EAiBQ,cnCvByB;AFgkPjC;;AqCviPA;;EAGI,gCnCzC2B;AFklP/B;;AqCviPA;EACE,yBnC5C6B;EmC6C7B,0BAA8C;EAC9C,cnCnD4B;EmCoD5B,iBAhDyB;EAiDzB,gBnCfe;EmCgBf,iBArD8B;EAsD9B,mBArDgC;ArC+lPlC;;AqCxiPA;EACE,qBAAqB;EACrB,aAAa;EACb,kBArD4B;EAsD5B,uBAAuB;ArC2iPzB;;AqC/iPA;EAMI,gCnC3D0B;EmC4D1B,mBAAmB;EACnB,cAAc;ArC6iPlB;;AqCrjPA;EAWM,4BnCnEwB;EmCoExB,cnCrEwB;AFmnP9B;;AqC5iPA;EAEI,cnCxE0B;AFsnP9B;;AqChjPA;EAIM,cnC3D4B;AF2mPlC;;AqC9iPA;EACE,mBAAmB;EACnB,cnC/E4B;EmCgF5B,aAAa;EACb,2BAA2B;EAC3B,qBAAqB;ArCijPvB;;AqCtjPA;EAOI,oBAAoB;ArCmjPxB;;AqC1jPA;EASI,YAAY;EACZ,cAAc;EACd,WAAW;ArCqjPf;;AqChkPA;EAaI,eAAe;ArCujPnB;;AqCpkPA;EAeI,0BnC5E8B;EmC6E9B,cnC7F0B;AFspP9B;;AqCzkPA;EAkBM,cnC/E4B;AF0oPlC;;AqC7kPA;EAoBI,8BnCjCc;EmCkCd,+BnClCc;AF+lPlB;;AqC3jPA;;EAEE,eAAe;ArC8jPjB;;AqChkPA;;EAII,4BnCjG0B;AFkqP9B;;AqC/jPA;EpC9FE,qBAAqB;EACrB,eoC8FgB;EpC7FhB,WoC6FqB;EpC5FrB,gBoC4FqB;EpC3FrB,kBAAkB;EAClB,mBAAmB;EACnB,UoCyFqB;EACrB,cnC1G4B;EmC2G5B,oBAAoB;ArCwkPtB;;AqC3kPA;EAKI,kBAAkB;EAClB,oBAAoB;ArC0kPxB;;AsCpqPA;ErCkCE,iCAAiC;EqC9BjC,oBAAoB;EACpB,aAAa;EACb,epCGW;EoCFX,8BAA8B;EAC9B,gBAAgB;EAChB,gBAAgB;EAChB,mBAAmB;AtCqqPrB;;AsC/qPA;EAYI,mBAAmB;EACnB,4BpC/B0B;EoCgC1B,0BAzC4B;EA0C5B,wBAzC0B;EA0C1B,cpCrC0B;EoCsC1B,aAAa;EACb,uBAAuB;EACvB,mBAA6C;EAC7C,kBAxCyB;EAyCzB,mBAAmB;AtCuqPvB;;AsC5rPA;EAuBM,4BpC7CwB;EoC8CxB,cpC9CwB;AFutP9B;;AsCjsPA;EA0BI,cAAc;AtC2qPlB;;AsCrsPA;EA6BQ,4BpCnC0B;EoCoC1B,cpCpC0B;AFgtPlC;;AsC1sPA;EAgCI,mBAAmB;EACnB,4BpCnD0B;EoCoD1B,0BA7D4B;EA8D5B,wBA7D0B;EA8D1B,aAAa;EACb,YAAY;EACZ,cAAc;EACd,2BAA2B;AtC8qP/B;;AsCrtPA;EAyCM,qBAAqB;AtCgrP3B;;AsCztPA;EA2CM,UAAU;EACV,uBAAuB;EACvB,oBAAoB;EACpB,qBAAqB;AtCkrP3B;;AsChuPA;EAgDM,yBAAyB;EACzB,oBAAoB;AtCorP1B;;AsCruPA;EAoDM,mBAAmB;AtCqrPzB;;AsCzuPA;EAsDM,kBAAkB;AtCurPxB;;AsC7uPA;EA0DM,uBAAuB;AtCurP7B;;AsCjvPA;EA6DM,yBAAyB;AtCwrP/B;;AsCrvPA;EAiEM,6BAA6B;EAC7B,0BAAkE;AtCwrPxE;;AsC1vPA;EAoEQ,4BpCnFsB;EoCoFtB,4BpCvFsB;AFixP9B;;AsC/vPA;EAyEU,uBpCtFqB;EoCuFrB,qBpC5FoB;EoC6FpB,2CAA2E;AtC0rPrF;;AsCrwPA;EA8EM,YAAY;EACZ,cAAc;AtC2rPpB;;AsC1wPA;EAkFM,qBpCpGwB;EoCqGxB,mBA5F+B;EA6F/B,iBA5F6B;EA6F7B,gBAAgB;EAChB,kBAAkB;AtC4rPxB;;AsClxPA;EAwFQ,4BpCvGsB;EoCwGtB,qBpC5GsB;EoC6GtB,UAAU;AtC8rPlB;;AsCxxPA;EA6FQ,iBAAgD;AtC+rPxD;;AsC5xPA;EA+FQ,0BpCtDI;AFuvPZ;;AsChyPA;EAiGQ,0BAAoE;AtCmsP5E;;AsCpyPA;EAoGU,yBpC1GwB;EoC2GxB,qBpC3GwB;EoC4GxB,W7BhDM;E6BiDN,UAAU;AtCosPpB;;AsC3yPA;EAyGM,mBAAmB;AtCssPzB;;AsC/yPA;EA6GU,mCpClEe;EoCmEf,gCpCnEe;EoCoEf,oBAAoB;AtCssP9B;;AsCrzPA;EAiHU,oCpCtEe;EoCuEf,iCpCvEe;EoCwEf,qBAAqB;AtCwsP/B;;AsC3zPA;EAsHI,kBpC5GY;AFqzPhB;;AsC/zPA;EAwHI,kBpChHY;AF2zPhB;;AsCn0PA;EA0HI,iBpCnHW;AFg0Pf;;AuCj2PA;EACE,cAAc;EACd,aAAa;EACb,YAAY;EACZ,cAAc;EACd,gBAPkB;AvC22PpB;;AuCn2PE;EACE,UAAU;AvCs2Pd;;AuCr2PE;EACE,UAAU;EACV,WAAW;AvCw2Pf;;AuCv2PE;EACE,UAAU;EACV,UAAU;AvC02Pd;;AuCz2PE;EACE,UAAU;EACV,eAAe;AvC42PnB;;AuC32PE;EACE,UAAU;EACV,UAAU;AvC82Pd;;AuC72PE;EACE,UAAU;EACV,eAAe;AvCg3PnB;;AuC/2PE;EACE,UAAU;EACV,UAAU;AvCk3Pd;;AuCj3PE;EACE,UAAU;EACV,UAAU;AvCo3Pd;;AuCn3PE;EACE,UAAU;EACV,UAAU;AvCs3Pd;;AuCr3PE;EACE,UAAU;EACV,UAAU;AvCw3Pd;;AuCv3PE;EACE,UAAU;EACV,UAAU;AvC03Pd;;AuCz3PE;EACE,gBAAgB;AvC43PpB;;AuC33PE;EACE,qBAAqB;AvC83PzB;;AuC73PE;EACE,gBAAgB;AvCg4PpB;;AuC/3PE;EACE,qBAAqB;AvCk4PzB;;AuCj4PE;EACE,gBAAgB;AvCo4PpB;;AuCn4PE;EACE,gBAAgB;AvCs4PpB;;AuCr4PE;EACE,gBAAgB;AvCw4PpB;;AuCv4PE;EACE,gBAAgB;AvC04PpB;;AuCz4PE;EACE,gBAAgB;AvC44PpB;;AuC14PI;EACE,UAAU;EACV,SAA0B;AvC64PhC;;AuC54PI;EACE,eAAgC;AvC+4PtC;;AuCn5PI;EACE,UAAU;EACV,eAA0B;AvCs5PhC;;AuCr5PI;EACE,qBAAgC;AvCw5PtC;;AuC55PI;EACE,UAAU;EACV,gBAA0B;AvC+5PhC;;AuC95PI;EACE,sBAAgC;AvCi6PtC;;AuCr6PI;EACE,UAAU;EACV,UAA0B;AvCw6PhC;;AuCv6PI;EACE,gBAAgC;AvC06PtC;;AuC96PI;EACE,UAAU;EACV,gBAA0B;AvCi7PhC;;AuCh7PI;EACE,sBAAgC;AvCm7PtC;;AuCv7PI;EACE,UAAU;EACV,gBAA0B;AvC07PhC;;AuCz7PI;EACE,sBAAgC;AvC47PtC;;AuCh8PI;EACE,UAAU;EACV,UAA0B;AvCm8PhC;;AuCl8PI;EACE,gBAAgC;AvCq8PtC;;AuCz8PI;EACE,UAAU;EACV,gBAA0B;AvC48PhC;;AuC38PI;EACE,sBAAgC;AvC88PtC;;AuCl9PI;EACE,UAAU;EACV,gBAA0B;AvCq9PhC;;AuCp9PI;EACE,sBAAgC;AvCu9PtC;;AuC39PI;EACE,UAAU;EACV,UAA0B;AvC89PhC;;AuC79PI;EACE,gBAAgC;AvCg+PtC;;AuCp+PI;EACE,UAAU;EACV,gBAA0B;AvCu+PhC;;AuCt+PI;EACE,sBAAgC;AvCy+PtC;;AuC7+PI;EACE,UAAU;EACV,gBAA0B;AvCg/PhC;;AuC/+PI;EACE,sBAAgC;AvCk/PtC;;AuCt/PI;EACE,UAAU;EACV,WAA0B;AvCy/PhC;;AuCx/PI;EACE,iBAAgC;AvC2/PtC;;ACz+PE;EsC/EF;IAgEM,UAAU;EvC6/Pd;EuC7jQF;IAkEM,UAAU;IACV,WAAW;EvC8/Pf;EuCjkQF;IAqEM,UAAU;IACV,UAAU;EvC+/Pd;EuCrkQF;IAwEM,UAAU;IACV,eAAe;EvCggQnB;EuCzkQF;IA2EM,UAAU;IACV,UAAU;EvCigQd;EuC7kQF;IA8EM,UAAU;IACV,eAAe;EvCkgQnB;EuCjlQF;IAiFM,UAAU;IACV,UAAU;EvCmgQd;EuCrlQF;IAoFM,UAAU;IACV,UAAU;EvCogQd;EuCzlQF;IAuFM,UAAU;IACV,UAAU;EvCqgQd;EuC7lQF;IA0FM,UAAU;IACV,UAAU;EvCsgQd;EuCjmQF;IA6FM,UAAU;IACV,UAAU;EvCugQd;EuCrmQF;IAgGM,gBAAgB;EvCwgQpB;EuCxmQF;IAkGM,qBAAqB;EvCygQzB;EuC3mQF;IAoGM,gBAAgB;EvC0gQpB;EuC9mQF;IAsGM,qBAAqB;EvC2gQzB;EuCjnQF;IAwGM,gBAAgB;EvC4gQpB;EuCpnQF;IA0GM,gBAAgB;EvC6gQpB;EuCvnQF;IA4GM,gBAAgB;EvC8gQpB;EuC1nQF;IA8GM,gBAAgB;EvC+gQpB;EuC7nQF;IAgHM,gBAAgB;EvCghQpB;EuChoQF;IAmHQ,UAAU;IACV,SAA0B;EvCghQhC;EuCpoQF;IAsHQ,eAAgC;EvCihQtC;EuCvoQF;IAmHQ,UAAU;IACV,eAA0B;EvCuhQhC;EuC3oQF;IAsHQ,qBAAgC;EvCwhQtC;EuC9oQF;IAmHQ,UAAU;IACV,gBAA0B;EvC8hQhC;EuClpQF;IAsHQ,sBAAgC;EvC+hQtC;EuCrpQF;IAmHQ,UAAU;IACV,UAA0B;EvCqiQhC;EuCzpQF;IAsHQ,gBAAgC;EvCsiQtC;EuC5pQF;IAmHQ,UAAU;IACV,gBAA0B;EvC4iQhC;EuChqQF;IAsHQ,sBAAgC;EvC6iQtC;EuCnqQF;IAmHQ,UAAU;IACV,gBAA0B;EvCmjQhC;EuCvqQF;IAsHQ,sBAAgC;EvCojQtC;EuC1qQF;IAmHQ,UAAU;IACV,UAA0B;EvC0jQhC;EuC9qQF;IAsHQ,gBAAgC;EvC2jQtC;EuCjrQF;IAmHQ,UAAU;IACV,gBAA0B;EvCikQhC;EuCrrQF;IAsHQ,sBAAgC;EvCkkQtC;EuCxrQF;IAmHQ,UAAU;IACV,gBAA0B;EvCwkQhC;EuC5rQF;IAsHQ,sBAAgC;EvCykQtC;EuC/rQF;IAmHQ,UAAU;IACV,UAA0B;EvC+kQhC;EuCnsQF;IAsHQ,gBAAgC;EvCglQtC;EuCtsQF;IAmHQ,UAAU;IACV,gBAA0B;EvCslQhC;EuC1sQF;IAsHQ,sBAAgC;EvCulQtC;EuC7sQF;IAmHQ,UAAU;IACV,gBAA0B;EvC6lQhC;EuCjtQF;IAsHQ,sBAAgC;EvC8lQtC;EuCptQF;IAmHQ,UAAU;IACV,WAA0B;EvComQhC;EuCxtQF;IAsHQ,iBAAgC;EvCqmQtC;AACF;;ACzoQE;EsCnFF;IA0HM,UAAU;EvCumQd;EuCjuQF;IA6HM,UAAU;IACV,WAAW;EvCumQf;EuCruQF;IAiIM,UAAU;IACV,UAAU;EvCumQd;EuCzuQF;IAqIM,UAAU;IACV,eAAe;EvCumQnB;EuC7uQF;IAyIM,UAAU;IACV,UAAU;EvCumQd;EuCjvQF;IA6IM,UAAU;IACV,eAAe;EvCumQnB;EuCrvQF;IAiJM,UAAU;IACV,UAAU;EvCumQd;EuCzvQF;IAqJM,UAAU;IACV,UAAU;EvCumQd;EuC7vQF;IAyJM,UAAU;IACV,UAAU;EvCumQd;EuCjwQF;IA6JM,UAAU;IACV,UAAU;EvCumQd;EuCrwQF;IAiKM,UAAU;IACV,UAAU;EvCumQd;EuCzwQF;IAqKM,gBAAgB;EvCumQpB;EuC5wQF;IAwKM,qBAAqB;EvCumQzB;EuC/wQF;IA2KM,gBAAgB;EvCumQpB;EuClxQF;IA8KM,qBAAqB;EvCumQzB;EuCrxQF;IAiLM,gBAAgB;EvCumQpB;EuCxxQF;IAoLM,gBAAgB;EvCumQpB;EuC3xQF;IAuLM,gBAAgB;EvCumQpB;EuC9xQF;IA0LM,gBAAgB;EvCumQpB;EuCjyQF;IA6LM,gBAAgB;EvCumQpB;EuCpyQF;IAiMQ,UAAU;IACV,SAA0B;EvCsmQhC;EuCxyQF;IAqMQ,eAAgC;EvCsmQtC;EuC3yQF;IAiMQ,UAAU;IACV,eAA0B;EvC6mQhC;EuC/yQF;IAqMQ,qBAAgC;EvC6mQtC;EuClzQF;IAiMQ,UAAU;IACV,gBAA0B;EvConQhC;EuCtzQF;IAqMQ,sBAAgC;EvConQtC;EuCzzQF;IAiMQ,UAAU;IACV,UAA0B;EvC2nQhC;EuC7zQF;IAqMQ,gBAAgC;EvC2nQtC;EuCh0QF;IAiMQ,UAAU;IACV,gBAA0B;EvCkoQhC;EuCp0QF;IAqMQ,sBAAgC;EvCkoQtC;EuCv0QF;IAiMQ,UAAU;IACV,gBAA0B;EvCyoQhC;EuC30QF;IAqMQ,sBAAgC;EvCyoQtC;EuC90QF;IAiMQ,UAAU;IACV,UAA0B;EvCgpQhC;EuCl1QF;IAqMQ,gBAAgC;EvCgpQtC;EuCr1QF;IAiMQ,UAAU;IACV,gBAA0B;EvCupQhC;EuCz1QF;IAqMQ,sBAAgC;EvCupQtC;EuC51QF;IAiMQ,UAAU;IACV,gBAA0B;EvC8pQhC;EuCh2QF;IAqMQ,sBAAgC;EvC8pQtC;EuCn2QF;IAiMQ,UAAU;IACV,UAA0B;EvCqqQhC;EuCv2QF;IAqMQ,gBAAgC;EvCqqQtC;EuC12QF;IAiMQ,UAAU;IACV,gBAA0B;EvC4qQhC;EuC92QF;IAqMQ,sBAAgC;EvC4qQtC;EuCj3QF;IAiMQ,UAAU;IACV,gBAA0B;EvCmrQhC;EuCr3QF;IAqMQ,sBAAgC;EvCmrQtC;EuCx3QF;IAiMQ,UAAU;IACV,WAA0B;EvC0rQhC;EuC53QF;IAqMQ,iBAAgC;EvC0rQtC;AACF;;ACryQE;EsC3FF;IAwMM,UAAU;EvC6rQd;EuCr4QF;IA0MM,UAAU;IACV,WAAW;EvC8rQf;EuCz4QF;IA6MM,UAAU;IACV,UAAU;EvC+rQd;EuC74QF;IAgNM,UAAU;IACV,eAAe;EvCgsQnB;EuCj5QF;IAmNM,UAAU;IACV,UAAU;EvCisQd;EuCr5QF;IAsNM,UAAU;IACV,eAAe;EvCksQnB;EuCz5QF;IAyNM,UAAU;IACV,UAAU;EvCmsQd;EuC75QF;IA4NM,UAAU;IACV,UAAU;EvCosQd;EuCj6QF;IA+NM,UAAU;IACV,UAAU;EvCqsQd;EuCr6QF;IAkOM,UAAU;IACV,UAAU;EvCssQd;EuCz6QF;IAqOM,UAAU;IACV,UAAU;EvCusQd;EuC76QF;IAwOM,gBAAgB;EvCwsQpB;EuCh7QF;IA0OM,qBAAqB;EvCysQzB;EuCn7QF;IA4OM,gBAAgB;EvC0sQpB;EuCt7QF;IA8OM,qBAAqB;EvC2sQzB;EuCz7QF;IAgPM,gBAAgB;EvC4sQpB;EuC57QF;IAkPM,gBAAgB;EvC6sQpB;EuC/7QF;IAoPM,gBAAgB;EvC8sQpB;EuCl8QF;IAsPM,gBAAgB;EvC+sQpB;EuCr8QF;IAwPM,gBAAgB;EvCgtQpB;EuCx8QF;IA2PQ,UAAU;IACV,SAA0B;EvCgtQhC;EuC58QF;IA8PQ,eAAgC;EvCitQtC;EuC/8QF;IA2PQ,UAAU;IACV,eAA0B;EvCutQhC;EuCn9QF;IA8PQ,qBAAgC;EvCwtQtC;EuCt9QF;IA2PQ,UAAU;IACV,gBAA0B;EvC8tQhC;EuC19QF;IA8PQ,sBAAgC;EvC+tQtC;EuC79QF;IA2PQ,UAAU;IACV,UAA0B;EvCquQhC;EuCj+QF;IA8PQ,gBAAgC;EvCsuQtC;EuCp+QF;IA2PQ,UAAU;IACV,gBAA0B;EvC4uQhC;EuCx+QF;IA8PQ,sBAAgC;EvC6uQtC;EuC3+QF;IA2PQ,UAAU;IACV,gBAA0B;EvCmvQhC;EuC/+QF;IA8PQ,sBAAgC;EvCovQtC;EuCl/QF;IA2PQ,UAAU;IACV,UAA0B;EvC0vQhC;EuCt/QF;IA8PQ,gBAAgC;EvC2vQtC;EuCz/QF;IA2PQ,UAAU;IACV,gBAA0B;EvCiwQhC;EuC7/QF;IA8PQ,sBAAgC;EvCkwQtC;EuChgRF;IA2PQ,UAAU;IACV,gBAA0B;EvCwwQhC;EuCpgRF;IA8PQ,sBAAgC;EvCywQtC;EuCvgRF;IA2PQ,UAAU;IACV,UAA0B;EvC+wQhC;EuC3gRF;IA8PQ,gBAAgC;EvCgxQtC;EuC9gRF;IA2PQ,UAAU;IACV,gBAA0B;EvCsxQhC;EuClhRF;IA8PQ,sBAAgC;EvCuxQtC;EuCrhRF;IA2PQ,UAAU;IACV,gBAA0B;EvC6xQhC;EuCzhRF;IA8PQ,sBAAgC;EvC8xQtC;EuC5hRF;IA2PQ,UAAU;IACV,WAA0B;EvCoyQhC;EuChiRF;IA8PQ,iBAAgC;EvCqyQtC;AACF;;ACr8QE;EsC/FF;IAiQM,UAAU;EvCwyQd;EuCziRF;IAmQM,UAAU;IACV,WAAW;EvCyyQf;EuC7iRF;IAsQM,UAAU;IACV,UAAU;EvC0yQd;EuCjjRF;IAyQM,UAAU;IACV,eAAe;EvC2yQnB;EuCrjRF;IA4QM,UAAU;IACV,UAAU;EvC4yQd;EuCzjRF;IA+QM,UAAU;IACV,eAAe;EvC6yQnB;EuC7jRF;IAkRM,UAAU;IACV,UAAU;EvC8yQd;EuCjkRF;IAqRM,UAAU;IACV,UAAU;EvC+yQd;EuCrkRF;IAwRM,UAAU;IACV,UAAU;EvCgzQd;EuCzkRF;IA2RM,UAAU;IACV,UAAU;EvCizQd;EuC7kRF;IA8RM,UAAU;IACV,UAAU;EvCkzQd;EuCjlRF;IAiSM,gBAAgB;EvCmzQpB;EuCplRF;IAmSM,qBAAqB;EvCozQzB;EuCvlRF;IAqSM,gBAAgB;EvCqzQpB;EuC1lRF;IAuSM,qBAAqB;EvCszQzB;EuC7lRF;IAySM,gBAAgB;EvCuzQpB;EuChmRF;IA2SM,gBAAgB;EvCwzQpB;EuCnmRF;IA6SM,gBAAgB;EvCyzQpB;EuCtmRF;IA+SM,gBAAgB;EvC0zQpB;EuCzmRF;IAiTM,gBAAgB;EvC2zQpB;EuC5mRF;IAoTQ,UAAU;IACV,SAA0B;EvC2zQhC;EuChnRF;IAuTQ,eAAgC;EvC4zQtC;EuCnnRF;IAoTQ,UAAU;IACV,eAA0B;EvCk0QhC;EuCvnRF;IAuTQ,qBAAgC;EvCm0QtC;EuC1nRF;IAoTQ,UAAU;IACV,gBAA0B;EvCy0QhC;EuC9nRF;IAuTQ,sBAAgC;EvC00QtC;EuCjoRF;IAoTQ,UAAU;IACV,UAA0B;EvCg1QhC;EuCroRF;IAuTQ,gBAAgC;EvCi1QtC;EuCxoRF;IAoTQ,UAAU;IACV,gBAA0B;EvCu1QhC;EuC5oRF;IAuTQ,sBAAgC;EvCw1QtC;EuC/oRF;IAoTQ,UAAU;IACV,gBAA0B;EvC81QhC;EuCnpRF;IAuTQ,sBAAgC;EvC+1QtC;EuCtpRF;IAoTQ,UAAU;IACV,UAA0B;EvCq2QhC;EuC1pRF;IAuTQ,gBAAgC;EvCs2QtC;EuC7pRF;IAoTQ,UAAU;IACV,gBAA0B;EvC42QhC;EuCjqRF;IAuTQ,sBAAgC;EvC62QtC;EuCpqRF;IAoTQ,UAAU;IACV,gBAA0B;EvCm3QhC;EuCxqRF;IAuTQ,sBAAgC;EvCo3QtC;EuC3qRF;IAoTQ,UAAU;IACV,UAA0B;EvC03QhC;EuC/qRF;IAuTQ,gBAAgC;EvC23QtC;EuClrRF;IAoTQ,UAAU;IACV,gBAA0B;EvCi4QhC;EuCtrRF;IAuTQ,sBAAgC;EvCk4QtC;EuCzrRF;IAoTQ,UAAU;IACV,gBAA0B;EvCw4QhC;EuC7rRF;IAuTQ,sBAAgC;EvCy4QtC;EuChsRF;IAoTQ,UAAU;IACV,WAA0B;EvC+4QhC;EuCpsRF;IAuTQ,iBAAgC;EvCg5QtC;AACF;;AC1lRI;EsC9GJ;IA0TM,UAAU;EvCm5Qd;EuC7sRF;IA4TM,UAAU;IACV,WAAW;EvCo5Qf;EuCjtRF;IA+TM,UAAU;IACV,UAAU;EvCq5Qd;EuCrtRF;IAkUM,UAAU;IACV,eAAe;EvCs5QnB;EuCztRF;IAqUM,UAAU;IACV,UAAU;EvCu5Qd;EuC7tRF;IAwUM,UAAU;IACV,eAAe;EvCw5QnB;EuCjuRF;IA2UM,UAAU;IACV,UAAU;EvCy5Qd;EuCruRF;IA8UM,UAAU;IACV,UAAU;EvC05Qd;EuCzuRF;IAiVM,UAAU;IACV,UAAU;EvC25Qd;EuC7uRF;IAoVM,UAAU;IACV,UAAU;EvC45Qd;EuCjvRF;IAuVM,UAAU;IACV,UAAU;EvC65Qd;EuCrvRF;IA0VM,gBAAgB;EvC85QpB;EuCxvRF;IA4VM,qBAAqB;EvC+5QzB;EuC3vRF;IA8VM,gBAAgB;EvCg6QpB;EuC9vRF;IAgWM,qBAAqB;EvCi6QzB;EuCjwRF;IAkWM,gBAAgB;EvCk6QpB;EuCpwRF;IAoWM,gBAAgB;EvCm6QpB;EuCvwRF;IAsWM,gBAAgB;EvCo6QpB;EuC1wRF;IAwWM,gBAAgB;EvCq6QpB;EuC7wRF;IA0WM,gBAAgB;EvCs6QpB;EuChxRF;IA6WQ,UAAU;IACV,SAA0B;EvCs6QhC;EuCpxRF;IAgXQ,eAAgC;EvCu6QtC;EuCvxRF;IA6WQ,UAAU;IACV,eAA0B;EvC66QhC;EuC3xRF;IAgXQ,qBAAgC;EvC86QtC;EuC9xRF;IA6WQ,UAAU;IACV,gBAA0B;EvCo7QhC;EuClyRF;IAgXQ,sBAAgC;EvCq7QtC;EuCryRF;IA6WQ,UAAU;IACV,UAA0B;EvC27QhC;EuCzyRF;IAgXQ,gBAAgC;EvC47QtC;EuC5yRF;IA6WQ,UAAU;IACV,gBAA0B;EvCk8QhC;EuChzRF;IAgXQ,sBAAgC;EvCm8QtC;EuCnzRF;IA6WQ,UAAU;IACV,gBAA0B;EvCy8QhC;EuCvzRF;IAgXQ,sBAAgC;EvC08QtC;EuC1zRF;IA6WQ,UAAU;IACV,UAA0B;EvCg9QhC;EuC9zRF;IAgXQ,gBAAgC;EvCi9QtC;EuCj0RF;IA6WQ,UAAU;IACV,gBAA0B;EvCu9QhC;EuCr0RF;IAgXQ,sBAAgC;EvCw9QtC;EuCx0RF;IA6WQ,UAAU;IACV,gBAA0B;EvC89QhC;EuC50RF;IAgXQ,sBAAgC;EvC+9QtC;EuC/0RF;IA6WQ,UAAU;IACV,UAA0B;EvCq+QhC;EuCn1RF;IAgXQ,gBAAgC;EvCs+QtC;EuCt1RF;IA6WQ,UAAU;IACV,gBAA0B;EvC4+QhC;EuC11RF;IAgXQ,sBAAgC;EvC6+QtC;EuC71RF;IA6WQ,UAAU;IACV,gBAA0B;EvCm/QhC;EuCj2RF;IAgXQ,sBAAgC;EvCo/QtC;EuCp2RF;IA6WQ,UAAU;IACV,WAA0B;EvC0/QhC;EuCx2RF;IAgXQ,iBAAgC;EvC2/QtC;AACF;;AC/uRI;EsC7HJ;IAmXM,UAAU;EvC8/Qd;EuCj3RF;IAqXM,UAAU;IACV,WAAW;EvC+/Qf;EuCr3RF;IAwXM,UAAU;IACV,UAAU;EvCggRd;EuCz3RF;IA2XM,UAAU;IACV,eAAe;EvCigRnB;EuC73RF;IA8XM,UAAU;IACV,UAAU;EvCkgRd;EuCj4RF;IAiYM,UAAU;IACV,eAAe;EvCmgRnB;EuCr4RF;IAoYM,UAAU;IACV,UAAU;EvCogRd;EuCz4RF;IAuYM,UAAU;IACV,UAAU;EvCqgRd;EuC74RF;IA0YM,UAAU;IACV,UAAU;EvCsgRd;EuCj5RF;IA6YM,UAAU;IACV,UAAU;EvCugRd;EuCr5RF;IAgZM,UAAU;IACV,UAAU;EvCwgRd;EuCz5RF;IAmZM,gBAAgB;EvCygRpB;EuC55RF;IAqZM,qBAAqB;EvC0gRzB;EuC/5RF;IAuZM,gBAAgB;EvC2gRpB;EuCl6RF;IAyZM,qBAAqB;EvC4gRzB;EuCr6RF;IA2ZM,gBAAgB;EvC6gRpB;EuCx6RF;IA6ZM,gBAAgB;EvC8gRpB;EuC36RF;IA+ZM,gBAAgB;EvC+gRpB;EuC96RF;IAiaM,gBAAgB;EvCghRpB;EuCj7RF;IAmaM,gBAAgB;EvCihRpB;EuCp7RF;IAsaQ,UAAU;IACV,SAA0B;EvCihRhC;EuCx7RF;IAyaQ,eAAgC;EvCkhRtC;EuC37RF;IAsaQ,UAAU;IACV,eAA0B;EvCwhRhC;EuC/7RF;IAyaQ,qBAAgC;EvCyhRtC;EuCl8RF;IAsaQ,UAAU;IACV,gBAA0B;EvC+hRhC;EuCt8RF;IAyaQ,sBAAgC;EvCgiRtC;EuCz8RF;IAsaQ,UAAU;IACV,UAA0B;EvCsiRhC;EuC78RF;IAyaQ,gBAAgC;EvCuiRtC;EuCh9RF;IAsaQ,UAAU;IACV,gBAA0B;EvC6iRhC;EuCp9RF;IAyaQ,sBAAgC;EvC8iRtC;EuCv9RF;IAsaQ,UAAU;IACV,gBAA0B;EvCojRhC;EuC39RF;IAyaQ,sBAAgC;EvCqjRtC;EuC99RF;IAsaQ,UAAU;IACV,UAA0B;EvC2jRhC;EuCl+RF;IAyaQ,gBAAgC;EvC4jRtC;EuCr+RF;IAsaQ,UAAU;IACV,gBAA0B;EvCkkRhC;EuCz+RF;IAyaQ,sBAAgC;EvCmkRtC;EuC5+RF;IAsaQ,UAAU;IACV,gBAA0B;EvCykRhC;EuCh/RF;IAyaQ,sBAAgC;EvC0kRtC;EuCn/RF;IAsaQ,UAAU;IACV,UAA0B;EvCglRhC;EuCv/RF;IAyaQ,gBAAgC;EvCilRtC;EuC1/RF;IAsaQ,UAAU;IACV,gBAA0B;EvCulRhC;EuC9/RF;IAyaQ,sBAAgC;EvCwlRtC;EuCjgSF;IAsaQ,UAAU;IACV,gBAA0B;EvC8lRhC;EuCrgSF;IAyaQ,sBAAgC;EvC+lRtC;EuCxgSF;IAsaQ,UAAU;IACV,WAA0B;EvCqmRhC;EuC5gSF;IAyaQ,iBAAgC;EvCsmRtC;AACF;;AuCrmRA;EACE,qBA9akB;EA+alB,sBA/akB;EAgblB,oBAhbkB;AvCwhSpB;;AuC3mRA;EAKI,uBAlbgB;AvC4hSpB;;AuC/mRA;EAOI,qCAA4C;AvC4mRhD;;AuCnnRA;EAUI,uBAAuB;AvC6mR3B;;AuCvnRA;EAYI,cAAc;EACd,eAAe;EACf,aAAa;AvC+mRjB;;AuC7nRA;EAgBM,SAAS;EACT,qBAAqB;AvCinR3B;;AuCloRA;EAmBM,qBAAqB;AvCmnR3B;;AuCtoRA;EAqBM,gBAAgB;AvCqnRtB;;AuC1oRA;EAuBI,aAAa;AvCunRjB;;AuC9oRA;EAyBI,eAAe;AvCynRnB;;AuClpRA;EA2BI,mBAAmB;AvC2nRvB;;AC9+RE;EsCwVF;IA+BM,aAAa;EvC4nRjB;AACF;;ACx+RE;EsC4UF;IAmCM,aAAa;EvC8nRjB;AACF;;AuC5nRE;EACE,oBAAY;EACZ,wCAAwC;EACxC,yCAAyC;AvC+nR7C;;AuCloRE;EAKI,8BAA8B;EAC9B,+BAA+B;AvCioRrC;;AuCvoRE;EASM,iBAAY;AvCkoRpB;;AC7gSE;EsCkYA;IAYQ,iBAAY;EvCooRpB;AACF;;AC/gSE;EsC8XA;IAeQ,iBAAY;EvCuoRpB;AACF;;ACjhSE;EsC0XA;IAkBQ,iBAAY;EvC0oRpB;AACF;;ACnhSE;EsCsXA;IAqBQ,iBAAY;EvC6oRpB;AACF;;ACrhSE;EsCkXA;IAwBQ,iBAAY;EvCgpRpB;AACF;;ACthSI;EsC6WF;IA2BQ,iBAAY;EvCmpRpB;AACF;;AClhSI;EsCmWF;IA8BQ,iBAAY;EvCspRpB;AACF;;ACnhSI;EsC8VF;IAiCQ,iBAAY;EvCypRpB;AACF;;AC/gSI;EsCoVF;IAoCQ,iBAAY;EvC4pRpB;AACF;;AuCjsRE;EASM,oBAAY;AvC4rRpB;;ACvkSE;EsCkYA;IAYQ,oBAAY;EvC8rRpB;AACF;;ACzkSE;EsC8XA;IAeQ,oBAAY;EvCisRpB;AACF;;AC3kSE;EsC0XA;IAkBQ,oBAAY;EvCosRpB;AACF;;AC7kSE;EsCsXA;IAqBQ,oBAAY;EvCusRpB;AACF;;AC/kSE;EsCkXA;IAwBQ,oBAAY;EvC0sRpB;AACF;;AChlSI;EsC6WF;IA2BQ,oBAAY;EvC6sRpB;AACF;;AC5kSI;EsCmWF;IA8BQ,oBAAY;EvCgtRpB;AACF;;AC7kSI;EsC8VF;IAiCQ,oBAAY;EvCmtRpB;AACF;;ACzkSI;EsCoVF;IAoCQ,oBAAY;EvCstRpB;AACF;;AuC3vRE;EASM,mBAAY;AvCsvRpB;;ACjoSE;EsCkYA;IAYQ,mBAAY;EvCwvRpB;AACF;;ACnoSE;EsC8XA;IAeQ,mBAAY;EvC2vRpB;AACF;;ACroSE;EsC0XA;IAkBQ,mBAAY;EvC8vRpB;AACF;;ACvoSE;EsCsXA;IAqBQ,mBAAY;EvCiwRpB;AACF;;ACzoSE;EsCkXA;IAwBQ,mBAAY;EvCowRpB;AACF;;AC1oSI;EsC6WF;IA2BQ,mBAAY;EvCuwRpB;AACF;;ACtoSI;EsCmWF;IA8BQ,mBAAY;EvC0wRpB;AACF;;ACvoSI;EsC8VF;IAiCQ,mBAAY;EvC6wRpB;AACF;;ACnoSI;EsCoVF;IAoCQ,mBAAY;EvCgxRpB;AACF;;AuCrzRE;EASM,oBAAY;AvCgzRpB;;AC3rSE;EsCkYA;IAYQ,oBAAY;EvCkzRpB;AACF;;AC7rSE;EsC8XA;IAeQ,oBAAY;EvCqzRpB;AACF;;AC/rSE;EsC0XA;IAkBQ,oBAAY;EvCwzRpB;AACF;;ACjsSE;EsCsXA;IAqBQ,oBAAY;EvC2zRpB;AACF;;ACnsSE;EsCkXA;IAwBQ,oBAAY;EvC8zRpB;AACF;;ACpsSI;EsC6WF;IA2BQ,oBAAY;EvCi0RpB;AACF;;AChsSI;EsCmWF;IA8BQ,oBAAY;EvCo0RpB;AACF;;ACjsSI;EsC8VF;IAiCQ,oBAAY;EvCu0RpB;AACF;;AC7rSI;EsCoVF;IAoCQ,oBAAY;EvC00RpB;AACF;;AuC/2RE;EASM,iBAAY;AvC02RpB;;ACrvSE;EsCkYA;IAYQ,iBAAY;EvC42RpB;AACF;;ACvvSE;EsC8XA;IAeQ,iBAAY;EvC+2RpB;AACF;;ACzvSE;EsC0XA;IAkBQ,iBAAY;EvCk3RpB;AACF;;AC3vSE;EsCsXA;IAqBQ,iBAAY;EvCq3RpB;AACF;;AC7vSE;EsCkXA;IAwBQ,iBAAY;EvCw3RpB;AACF;;AC9vSI;EsC6WF;IA2BQ,iBAAY;EvC23RpB;AACF;;AC1vSI;EsCmWF;IA8BQ,iBAAY;EvC83RpB;AACF;;AC3vSI;EsC8VF;IAiCQ,iBAAY;EvCi4RpB;AACF;;ACvvSI;EsCoVF;IAoCQ,iBAAY;EvCo4RpB;AACF;;AuCz6RE;EASM,oBAAY;AvCo6RpB;;AC/ySE;EsCkYA;IAYQ,oBAAY;EvCs6RpB;AACF;;ACjzSE;EsC8XA;IAeQ,oBAAY;EvCy6RpB;AACF;;ACnzSE;EsC0XA;IAkBQ,oBAAY;EvC46RpB;AACF;;ACrzSE;EsCsXA;IAqBQ,oBAAY;EvC+6RpB;AACF;;ACvzSE;EsCkXA;IAwBQ,oBAAY;EvCk7RpB;AACF;;ACxzSI;EsC6WF;IA2BQ,oBAAY;EvCq7RpB;AACF;;ACpzSI;EsCmWF;IA8BQ,oBAAY;EvCw7RpB;AACF;;ACrzSI;EsC8VF;IAiCQ,oBAAY;EvC27RpB;AACF;;ACjzSI;EsCoVF;IAoCQ,oBAAY;EvC87RpB;AACF;;AuCn+RE;EASM,mBAAY;AvC89RpB;;ACz2SE;EsCkYA;IAYQ,mBAAY;EvCg+RpB;AACF;;AC32SE;EsC8XA;IAeQ,mBAAY;EvCm+RpB;AACF;;AC72SE;EsC0XA;IAkBQ,mBAAY;EvCs+RpB;AACF;;AC/2SE;EsCsXA;IAqBQ,mBAAY;EvCy+RpB;AACF;;ACj3SE;EsCkXA;IAwBQ,mBAAY;EvC4+RpB;AACF;;ACl3SI;EsC6WF;IA2BQ,mBAAY;EvC++RpB;AACF;;AC92SI;EsCmWF;IA8BQ,mBAAY;EvCk/RpB;AACF;;AC/2SI;EsC8VF;IAiCQ,mBAAY;EvCq/RpB;AACF;;AC32SI;EsCoVF;IAoCQ,mBAAY;EvCw/RpB;AACF;;AuC7hSE;EASM,oBAAY;AvCwhSpB;;ACn6SE;EsCkYA;IAYQ,oBAAY;EvC0hSpB;AACF;;ACr6SE;EsC8XA;IAeQ,oBAAY;EvC6hSpB;AACF;;ACv6SE;EsC0XA;IAkBQ,oBAAY;EvCgiSpB;AACF;;ACz6SE;EsCsXA;IAqBQ,oBAAY;EvCmiSpB;AACF;;AC36SE;EsCkXA;IAwBQ,oBAAY;EvCsiSpB;AACF;;AC56SI;EsC6WF;IA2BQ,oBAAY;EvCyiSpB;AACF;;ACx6SI;EsCmWF;IA8BQ,oBAAY;EvC4iSpB;AACF;;ACz6SI;EsC8VF;IAiCQ,oBAAY;EvC+iSpB;AACF;;ACr6SI;EsCoVF;IAoCQ,oBAAY;EvCkjSpB;AACF;;AuCvlSE;EASM,iBAAY;AvCklSpB;;AC79SE;EsCkYA;IAYQ,iBAAY;EvColSpB;AACF;;AC/9SE;EsC8XA;IAeQ,iBAAY;EvCulSpB;AACF;;ACj+SE;EsC0XA;IAkBQ,iBAAY;EvC0lSpB;AACF;;ACn+SE;EsCsXA;IAqBQ,iBAAY;EvC6lSpB;AACF;;ACr+SE;EsCkXA;IAwBQ,iBAAY;EvCgmSpB;AACF;;ACt+SI;EsC6WF;IA2BQ,iBAAY;EvCmmSpB;AACF;;ACl+SI;EsCmWF;IA8BQ,iBAAY;EvCsmSpB;AACF;;ACn+SI;EsC8VF;IAiCQ,iBAAY;EvCymSpB;AACF;;AC/9SI;EsCoVF;IAoCQ,iBAAY;EvC4mSpB;AACF;;AwClmTA;EACE,oBAAoB;EACpB,cAAc;EACd,aAAa;EACb,YAAY;EACZ,cAAc;EACd,+BAAuB;EAAvB,4BAAuB;EAAvB,uBAAuB;AxCqmTzB;;AwC3mTA;EASI,qBAA+B;EAC/B,sBAAgC;EAChC,oBAA8B;AxCsmTlC;;AwCjnTA;EAaM,uBAAiC;AxCwmTvC;;AwCrnTA;EAeM,sBAjBgB;AxC2nTtB;;AwCznTA;EAiBI,oBAAoB;AxC4mTxB;;AwC7nTA;EAmBI,gBArBkB;AxCmoTtB;;AwCjoTA;EAqBI,sBAAsB;AxCgnT1B;;AwCroTA;EAuBM,gCAAgC;AxCknTtC;;ACtjTE;EuCnFF;IA2BM,aAAa;ExCmnTjB;EwC9oTF;IA8BQ,UAAU;IACV,eAAuB;ExCmnT7B;EwClpTF;IA8BQ,UAAU;IACV,gBAAuB;ExCunT7B;EwCtpTF;IA8BQ,UAAU;IACV,UAAuB;ExC2nT7B;EwC1pTF;IA8BQ,UAAU;IACV,gBAAuB;ExC+nT7B;EwC9pTF;IA8BQ,UAAU;IACV,gBAAuB;ExCmoT7B;EwClqTF;IA8BQ,UAAU;IACV,UAAuB;ExCuoT7B;EwCtqTF;IA8BQ,UAAU;IACV,gBAAuB;ExC2oT7B;EwC1qTF;IA8BQ,UAAU;IACV,gBAAuB;ExC+oT7B;EwC9qTF;IA8BQ,UAAU;IACV,UAAuB;ExCmpT7B;EwClrTF;IA8BQ,UAAU;IACV,gBAAuB;ExCupT7B;EwCtrTF;IA8BQ,UAAU;IACV,gBAAuB;ExC2pT7B;EwC1rTF;IA8BQ,UAAU;IACV,WAAuB;ExC+pT7B;AACF;;AyC3rTA;EACE,oBAAoB;EACpB,aAAa;EACb,sBAAsB;EACtB,8BAA8B;AzC8rThC;;AyClsTA;EAMI,gBAAgB;AzCgsTpB;;AyCtsTA;EASM,mBAAmB;AzCisTzB;;AyC1sTA;EAeM,uBvCNyB;EuCOzB,cvCpBuB;AFmtT7B;;AyC/sTA;;EAmBQ,cAAc;AzCisTtB;;AyCptTA;EAqBQ,cvCzBqB;AF4tT7B;;AyCxtTA;EAuBQ,4BvC3BqB;AFguT7B;;AyC5tTA;;EA0BU,cvC9BmB;AFquT7B;;AC1oTE;EwCvFF;IA6BU,uBvCpBqB;EF6tT7B;AACF;;AyCvuTA;;EAgCQ,4BvCpCqB;AFgvT7B;;AyC5uTA;;;EAqCU,yBhCkEuB;EgCjEvB,cvC1CmB;AFuvT7B;;AyCnvTA;EAyCU,cvC7CmB;EuC8CnB,YAAY;AzC8sTtB;;AyCxvTA;EA4CY,UAAU;AzCgtTtB;;AyC5vTA;EA+CY,UAAU;AzCitTtB;;AyChwTA;EAmDY,cvCvDiB;AFwwT7B;;AyCpwTA;EAqDc,uCvCzDe;AF4wT7B;;AyCxwTA;EAyDc,yBvC7De;EuC8Df,qBvC9De;EuC+Df,YvClDiB;AFqwT/B;;AyC9wTA;EAiEU,4EAAyG;AzCitTnH;;ACvsTE;EwC3EF;IAoEc,4EAAyG;EzCmtTrH;AACF;;AyCxxTA;EAeM,yBvCnBuB;EuCoBvB,YvCPyB;AFoxT/B;;AyC7xTA;;EAmBQ,cAAc;AzC+wTtB;;AyClyTA;EAqBQ,YvCZuB;AF6xT/B;;AyCtyTA;EAuBQ,+BvCduB;AFiyT/B;;AyC1yTA;;EA0BU,YvCjBqB;AFsyT/B;;ACxtTE;EwCvFF;IA6BU,yBvCjCmB;EFwzT3B;AACF;;AyCrzTA;;EAgCQ,+BvCvBuB;AFizT/B;;AyC1zTA;;;EAqCU,uBhCkEuB;EgCjEvB,YvC7BqB;AFwzT/B;;AyCj0TA;EAyCU,YvChCqB;EuCiCrB,YAAY;AzC4xTtB;;AyCt0TA;EA4CY,UAAU;AzC8xTtB;;AyC10TA;EA+CY,UAAU;AzC+xTtB;;AyC90TA;EAmDY,YvC1CmB;AFy0T/B;;AyCl1TA;EAqDc,uCvCzDe;AF01T7B;;AyCt1TA;EAyDc,uBvChDiB;EuCiDjB,mBvCjDiB;EuCkDjB,cvC/De;AFg2T7B;;AyC51TA;EAiEU,8EAAyG;AzC+xTnH;;ACrxTE;EwC3EF;IAoEc,8EAAyG;EzCiyTrH;AACF;;AyCt2TA;EAeM,4BvCRwB;EuCSxB,yBhC0De;ATiyTrB;;AyC32TA;;EAmBQ,cAAc;AzC61TtB;;AyCh3TA;EAqBQ,yBhCqDa;AT0yTrB;;AyCp3TA;EAuBQ,yBhCmDa;AT8yTrB;;AyCx3TA;;EA0BU,yBhCgDW;ATmzTrB;;ACtyTE;EwCvFF;IA6BU,4BvCtBoB;EF23T5B;AACF;;AyCn4TA;;EAgCQ,yBhC0Ca;AT8zTrB;;AyCx4TA;;;EAqCU,yBhCkEuB;EgCjEvB,yBhCoCW;ATq0TrB;;AyC/4TA;EAyCU,yBhCiCW;EgChCX,YAAY;AzC02TtB;;AyCp5TA;EA4CY,UAAU;AzC42TtB;;AyCx5TA;EA+CY,UAAU;AzC62TtB;;AyC55TA;EAmDY,yBhCuBS;ATs1TrB;;AyCh6TA;EAqDc,uCvCzDe;AFw6T7B;;AyCp6TA;EAyDc,oChCiBO;EgChBP,gChCgBO;EgCfP,iBvCpDgB;AFm6T9B;;AyC16TA;EAiEU,iFAAyG;AzC62TnH;;ACn2TE;EwC3EF;IAoEc,iFAAyG;EzC+2TrH;AACF;;AyCp7TA;EAeM,yBvCfwB;EuCgBxB,WhC4DU;AT62ThB;;AyCz7TA;;EAmBQ,cAAc;AzC26TtB;;AyC97TA;EAqBQ,WhCuDQ;ATs3ThB;;AyCl8TA;EAuBQ,+BhCqDQ;AT03ThB;;AyCt8TA;;EA0BU,WhCkDM;AT+3ThB;;ACp3TE;EwCvFF;IA6BU,yBvC7BoB;EFg9T5B;AACF;;AyCj9TA;;EAgCQ,+BhC4CQ;AT04ThB;;AyCt9TA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;ATi5ThB;;AyC79TA;EAyCU,WhCmCM;EgClCN,YAAY;AzCw7TtB;;AyCl+TA;EA4CY,UAAU;AzC07TtB;;AyCt+TA;EA+CY,UAAU;AzC27TtB;;AyC1+TA;EAmDY,WhCyBI;ATk6ThB;;AyC9+TA;EAqDc,uCvCzDe;AFs/T7B;;AyCl/TA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvC3DgB;AFw/T9B;;AyCx/TA;EAiEU,gFAAyG;AzC27TnH;;ACj7TE;EwC3EF;IAoEc,gFAAyG;EzC67TrH;AACF;;AyClgUA;EAeM,yBvCD4B;EuCE5B,WhC4DU;AT27ThB;;AyCvgUA;;EAmBQ,cAAc;AzCy/TtB;;AyC5gUA;EAqBQ,WhCuDQ;ATo8ThB;;AyChhUA;EAuBQ,+BhCqDQ;ATw8ThB;;AyCphUA;;EA0BU,WhCkDM;AT68ThB;;ACl8TE;EwCvFF;IA6BU,yBvCfwB;EFghUhC;AACF;;AyC/hUA;;EAgCQ,+BhC4CQ;ATw9ThB;;AyCpiUA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;AT+9ThB;;AyC3iUA;EAyCU,WhCmCM;EgClCN,YAAY;AzCsgUtB;;AyChjUA;EA4CY,UAAU;AzCwgUtB;;AyCpjUA;EA+CY,UAAU;AzCygUtB;;AyCxjUA;EAmDY,WhCyBI;ATg/ThB;;AyC5jUA;EAqDc,uCvCzDe;AFokU7B;;AyChkUA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvC7CoB;AFwjUlC;;AyCtkUA;EAiEU,gFAAyG;AzCygUnH;;AC//TE;EwC3EF;IAoEc,gFAAyG;EzC2gUrH;AACF;;AyChlUA;EAeM,yBvCC4B;EuCA5B,WhC4DU;ATygUhB;;AyCrlUA;;EAmBQ,cAAc;AzCukUtB;;AyC1lUA;EAqBQ,WhCuDQ;ATkhUhB;;AyC9lUA;EAuBQ,+BhCqDQ;ATshUhB;;AyClmUA;;EA0BU,WhCkDM;AT2hUhB;;AChhUE;EwCvFF;IA6BU,yBvCbwB;EF4lUhC;AACF;;AyC7mUA;;EAgCQ,+BhC4CQ;ATsiUhB;;AyClnUA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;AT6iUhB;;AyCznUA;EAyCU,WhCmCM;EgClCN,YAAY;AzColUtB;;AyC9nUA;EA4CY,UAAU;AzCslUtB;;AyCloUA;EA+CY,UAAU;AzCulUtB;;AyCtoUA;EAmDY,WhCyBI;AT8jUhB;;AyC1oUA;EAqDc,uCvCzDe;AFkpU7B;;AyC9oUA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvC3CoB;AFooUlC;;AyCppUA;EAiEU,gFAAyG;AzCulUnH;;AC7kUE;EwC3EF;IAoEc,gFAAyG;EzCylUrH;AACF;;AyC9pUA;EAeM,yBvCA4B;EuCC5B,WhC4DU;ATulUhB;;AyCnqUA;;EAmBQ,cAAc;AzCqpUtB;;AyCxqUA;EAqBQ,WhCuDQ;ATgmUhB;;AyC5qUA;EAuBQ,+BhCqDQ;ATomUhB;;AyChrUA;;EA0BU,WhCkDM;ATymUhB;;AC9lUE;EwCvFF;IA6BU,yBvCdwB;EF2qUhC;AACF;;AyC3rUA;;EAgCQ,+BhC4CQ;ATonUhB;;AyChsUA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;AT2nUhB;;AyCvsUA;EAyCU,WhCmCM;EgClCN,YAAY;AzCkqUtB;;AyC5sUA;EA4CY,UAAU;AzCoqUtB;;AyChtUA;EA+CY,UAAU;AzCqqUtB;;AyCptUA;EAmDY,WhCyBI;AT4oUhB;;AyCxtUA;EAqDc,uCvCzDe;AFguU7B;;AyC5tUA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvC5CoB;AFmtUlC;;AyCluUA;EAiEU,gFAAyG;AzCqqUnH;;AC3pUE;EwC3EF;IAoEc,gFAAyG;EzCuqUrH;AACF;;AyC5uUA;EAeM,yBvCF4B;EuCG5B,WhC4DU;ATqqUhB;;AyCjvUA;;EAmBQ,cAAc;AzCmuUtB;;AyCtvUA;EAqBQ,WhCuDQ;AT8qUhB;;AyC1vUA;EAuBQ,+BhCqDQ;ATkrUhB;;AyC9vUA;;EA0BU,WhCkDM;ATurUhB;;AC5qUE;EwCvFF;IA6BU,yBvChBwB;EF2vUhC;AACF;;AyCzwUA;;EAgCQ,+BhC4CQ;ATksUhB;;AyC9wUA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;ATysUhB;;AyCrxUA;EAyCU,WhCmCM;EgClCN,YAAY;AzCgvUtB;;AyC1xUA;EA4CY,UAAU;AzCkvUtB;;AyC9xUA;EA+CY,UAAU;AzCmvUtB;;AyClyUA;EAmDY,WhCyBI;AT0tUhB;;AyCtyUA;EAqDc,uCvCzDe;AF8yU7B;;AyC1yUA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvC9CoB;AFmyUlC;;AyChzUA;EAiEU,gFAAyG;AzCmvUnH;;ACzuUE;EwC3EF;IAoEc,gFAAyG;EzCqvUrH;AACF;;AyC1zUA;EAeM,yBvCH4B;EuCI5B,yBhC0De;ATqvUrB;;AyC/zUA;;EAmBQ,cAAc;AzCizUtB;;AyCp0UA;EAqBQ,yBhCqDa;AT8vUrB;;AyCx0UA;EAuBQ,yBhCmDa;ATkwUrB;;AyC50UA;;EA0BU,yBhCgDW;ATuwUrB;;AC1vUE;EwCvFF;IA6BU,yBvCjBwB;EF00UhC;AACF;;AyCv1UA;;EAgCQ,yBhC0Ca;ATkxUrB;;AyC51UA;;;EAqCU,yBhCkEuB;EgCjEvB,yBhCoCW;ATyxUrB;;AyCn2UA;EAyCU,yBhCiCW;EgChCX,YAAY;AzC8zUtB;;AyCx2UA;EA4CY,UAAU;AzCg0UtB;;AyC52UA;EA+CY,UAAU;AzCi0UtB;;AyCh3UA;EAmDY,yBhCuBS;AT0yUrB;;AyCp3UA;EAqDc,uCvCzDe;AF43U7B;;AyCx3UA;EAyDc,oChCiBO;EgChBP,gChCgBO;EgCfP,cvC/CoB;AFk3UlC;;AyC93UA;EAiEU,gFAAyG;AzCi0UnH;;ACvzUE;EwC3EF;IAoEc,gFAAyG;EzCm0UrH;AACF;;AyCx4UA;EAeM,yBvCG2B;EuCF3B,WhC4DU;ATi0UhB;;AyC74UA;;EAmBQ,cAAc;AzC+3UtB;;AyCl5UA;EAqBQ,WhCuDQ;AT00UhB;;AyCt5UA;EAuBQ,+BhCqDQ;AT80UhB;;AyC15UA;;EA0BU,WhCkDM;ATm1UhB;;ACx0UE;EwCvFF;IA6BU,yBvCXuB;EFk5U/B;AACF;;AyCr6UA;;EAgCQ,+BhC4CQ;AT81UhB;;AyC16UA;;;EAqCU,yBhCkEuB;EgCjEvB,WhCsCM;ATq2UhB;;AyCj7UA;EAyCU,WhCmCM;EgClCN,YAAY;AzC44UtB;;AyCt7UA;EA4CY,UAAU;AzC84UtB;;AyC17UA;EA+CY,UAAU;AzC+4UtB;;AyC97UA;EAmDY,WhCyBI;ATs3UhB;;AyCl8UA;EAqDc,uCvCzDe;AF08U7B;;AyCt8UA;EAyDc,sBhCmBE;EgClBF,kBhCkBE;EgCjBF,cvCzCmB;AF07UjC;;AyC58UA;EAiEU,gFAAyG;AzC+4UnH;;ACr4UE;EwC3EF;IAoEc,gFAAyG;EzCi5UrH;AACF;;AyCt9UA;EAwEM,eA7E0B;AzC+9UhC;;AC34UE;EwC/EF;IA4EQ,oBAhF8B;EzCm+UpC;AACF;;ACj5UE;EwC/EF;IAgFQ,qBAnF8B;EzCw+UpC;AACF;;AyCt+UA;EAqFM,mBAAmB;EACnB,aAAa;AzCq5UnB;;AyC3+UA;EAwFQ,YAAY;EACZ,cAAc;AzCu5UtB;;AyCh/UA;EA2FI,gBAAgB;AzCy5UpB;;AyCp/UA;EA6FI,iBAAiB;AzC25UrB;;AyCv5UA;EAEE,gBAAgB;AzCy5UlB;;AyC35UA;EAII,SAAS;EACT,gBAAgB;EAChB,eAAe;EACf,kBAAkB;EAClB,QAAQ;EACR,qCAAqC;AzC25UzC;;AyCp6UA;EAYI,YAAY;AzC45UhB;;AC97UE;EwCsBF;IAeI,aAAa;EzC85Uf;AACF;;AyC75UA;EACE,kBAAkB;AzCg6UpB;;ACx8UE;EwCuCF;IAKM,aAAa;EzCi6UjB;EyCt6UF;IAOQ,sBAAsB;EzCk6U5B;AACF;;AC78UE;EwCmCF;IASI,aAAa;IACb,uBAAuB;EzCs6UzB;EyCh7UF;IAYM,oBAAoB;EzCu6UxB;AACF;;AyCp6UA;;EAEE,YAAY;EACZ,cAAc;AzCu6UhB;;AyCr6UA;EACE,YAAY;EACZ,cAAc;EACd,oBAhJ6B;AzCwjV/B;;A0CpjVA;EACE,oBAL2B;A1C4jV7B;;AC39UE;EyC7FF;IAMM,oBAT8B;E1CgkVlC;E0C7jVF;IAQM,qBAV8B;E1CkkVlC;AACF;;A2CjkVA;EACE,yBzCS4B;EyCR5B,yBAJ+B;A3CwkVjC","file":"bulma.css"} \ No newline at end of file diff --git a/html/css/bulma.min.css b/html/css/bulma.min.css deleted file mode 100644 index 74d8cf8..0000000 --- a/html/css/bulma.min.css +++ /dev/null @@ -1 +0,0 @@ -/*! bulma.io v0.8.2 | MIT License | github.com/jgthms/bulma */@-webkit-keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}@keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}.breadcrumb,.button,.delete,.file,.is-unselectable,.modal-close,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.tabs{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless)::after,.select:not(.is-multiple):not(.is-loading)::after{border:3px solid transparent;border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.block:not(:last-child),.box:not(:last-child),.breadcrumb:not(:last-child),.content:not(:last-child),.highlight:not(:last-child),.level:not(:last-child),.list:not(:last-child),.message:not(:last-child),.notification:not(:last-child),.pagination:not(:last-child),.progress:not(:last-child),.subtitle:not(:last-child),.table-container:not(:last-child),.table:not(:last-child),.tabs:not(:last-child),.title:not(:last-child){margin-bottom:1.5rem}.delete,.modal-close{-moz-appearance:none;-webkit-appearance:none;background-color:rgba(10,10,10,.2);border:none;border-radius:290486px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:20px;max-height:20px;max-width:20px;min-height:20px;min-width:20px;outline:0;position:relative;vertical-align:top;width:20px}.delete::after,.delete::before,.modal-close::after,.modal-close::before{background-color:#fff;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.delete::before,.modal-close::before{height:2px;width:50%}.delete::after,.modal-close::after{height:50%;width:2px}.delete:focus,.delete:hover,.modal-close:focus,.modal-close:hover{background-color:rgba(10,10,10,.3)}.delete:active,.modal-close:active{background-color:rgba(10,10,10,.4)}.is-small.delete,.is-small.modal-close{height:16px;max-height:16px;max-width:16px;min-height:16px;min-width:16px;width:16px}.is-medium.delete,.is-medium.modal-close{height:24px;max-height:24px;max-width:24px;min-height:24px;min-width:24px;width:24px}.is-large.delete,.is-large.modal-close{height:32px;max-height:32px;max-width:32px;min-height:32px;min-width:32px;width:32px}.button.is-loading::after,.control.is-loading::after,.loader,.select.is-loading::after{-webkit-animation:spinAround .5s infinite linear;animation:spinAround .5s infinite linear;border:2px solid #dbdbdb;border-radius:290486px;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:1em;position:relative;width:1em}.hero-video,.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img,.is-overlay,.modal,.modal-background{bottom:0;left:0;position:absolute;right:0;top:0}.button,.file-cta,.file-name,.input,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.select select,.textarea{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid transparent;border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.button:active,.button:focus,.file-cta:active,.file-cta:focus,.file-name:active,.file-name:focus,.input:active,.input:focus,.is-active.button,.is-active.file-cta,.is-active.file-name,.is-active.input,.is-active.pagination-ellipsis,.is-active.pagination-link,.is-active.pagination-next,.is-active.pagination-previous,.is-active.textarea,.is-focused.button,.is-focused.file-cta,.is-focused.file-name,.is-focused.input,.is-focused.pagination-ellipsis,.is-focused.pagination-link,.is-focused.pagination-next,.is-focused.pagination-previous,.is-focused.textarea,.pagination-ellipsis:active,.pagination-ellipsis:focus,.pagination-link:active,.pagination-link:focus,.pagination-next:active,.pagination-next:focus,.pagination-previous:active,.pagination-previous:focus,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{outline:0}.button[disabled],.file-cta[disabled],.file-name[disabled],.input[disabled],.pagination-ellipsis[disabled],.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .button,fieldset[disabled] .file-cta,fieldset[disabled] .file-name,fieldset[disabled] .input,fieldset[disabled] .pagination-ellipsis,fieldset[disabled] .pagination-link,fieldset[disabled] .pagination-next,fieldset[disabled] .pagination-previous,fieldset[disabled] .select select,fieldset[disabled] .textarea{cursor:not-allowed}/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */blockquote,body,dd,dl,dt,fieldset,figure,h1,h2,h3,h4,h5,h6,hr,html,iframe,legend,li,ol,p,pre,textarea,ul{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:400}ul{list-style:none}button,input,select,textarea{margin:0}html{box-sizing:border-box}*,::after,::before{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}td:not([align]),th:not([align]){text-align:left}html{background-color:#fff;font-size:16px;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:300px;overflow-x:hidden;overflow-y:scroll;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;text-size-adjust:100%}article,aside,figure,footer,header,hgroup,section{display:block}body,button,input,select,textarea{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif}code,pre{-moz-osx-font-smoothing:auto;-webkit-font-smoothing:auto;font-family:monospace}body{color:#4a4a4a;font-size:1em;font-weight:400;line-height:1.5}a{color:#3273dc;cursor:pointer;text-decoration:none}a strong{color:currentColor}a:hover{color:#363636}code{background-color:#f5f5f5;color:#f14668;font-size:.875em;font-weight:400;padding:.25em .5em .25em}hr{background-color:#f5f5f5;border:none;display:block;height:2px;margin:1.5rem 0}img{height:auto;max-width:100%}input[type=checkbox],input[type=radio]{vertical-align:baseline}small{font-size:.875em}span{font-style:inherit;font-weight:inherit}strong{color:#363636;font-weight:700}fieldset{border:none}pre{-webkit-overflow-scrolling:touch;background-color:#f5f5f5;color:#4a4a4a;font-size:.875em;overflow-x:auto;padding:1.25rem 1.5rem;white-space:pre;word-wrap:normal}pre code{background-color:transparent;color:currentColor;font-size:1em;padding:0}table td,table th{vertical-align:top}table td:not([align]),table th:not([align]){text-align:left}table th{color:#363636}.is-clearfix::after{clear:both;content:" ";display:table}.is-pulled-left{float:left!important}.is-pulled-right{float:right!important}.is-clipped{overflow:hidden!important}.is-size-1{font-size:3rem!important}.is-size-2{font-size:2.5rem!important}.is-size-3{font-size:2rem!important}.is-size-4{font-size:1.5rem!important}.is-size-5{font-size:1.25rem!important}.is-size-6{font-size:1rem!important}.is-size-7{font-size:.75rem!important}@media screen and (max-width:768px){.is-size-1-mobile{font-size:3rem!important}.is-size-2-mobile{font-size:2.5rem!important}.is-size-3-mobile{font-size:2rem!important}.is-size-4-mobile{font-size:1.5rem!important}.is-size-5-mobile{font-size:1.25rem!important}.is-size-6-mobile{font-size:1rem!important}.is-size-7-mobile{font-size:.75rem!important}}@media screen and (min-width:769px),print{.is-size-1-tablet{font-size:3rem!important}.is-size-2-tablet{font-size:2.5rem!important}.is-size-3-tablet{font-size:2rem!important}.is-size-4-tablet{font-size:1.5rem!important}.is-size-5-tablet{font-size:1.25rem!important}.is-size-6-tablet{font-size:1rem!important}.is-size-7-tablet{font-size:.75rem!important}}@media screen and (max-width:1023px){.is-size-1-touch{font-size:3rem!important}.is-size-2-touch{font-size:2.5rem!important}.is-size-3-touch{font-size:2rem!important}.is-size-4-touch{font-size:1.5rem!important}.is-size-5-touch{font-size:1.25rem!important}.is-size-6-touch{font-size:1rem!important}.is-size-7-touch{font-size:.75rem!important}}@media screen and (min-width:1024px){.is-size-1-desktop{font-size:3rem!important}.is-size-2-desktop{font-size:2.5rem!important}.is-size-3-desktop{font-size:2rem!important}.is-size-4-desktop{font-size:1.5rem!important}.is-size-5-desktop{font-size:1.25rem!important}.is-size-6-desktop{font-size:1rem!important}.is-size-7-desktop{font-size:.75rem!important}}@media screen and (min-width:1216px){.is-size-1-widescreen{font-size:3rem!important}.is-size-2-widescreen{font-size:2.5rem!important}.is-size-3-widescreen{font-size:2rem!important}.is-size-4-widescreen{font-size:1.5rem!important}.is-size-5-widescreen{font-size:1.25rem!important}.is-size-6-widescreen{font-size:1rem!important}.is-size-7-widescreen{font-size:.75rem!important}}@media screen and (min-width:1408px){.is-size-1-fullhd{font-size:3rem!important}.is-size-2-fullhd{font-size:2.5rem!important}.is-size-3-fullhd{font-size:2rem!important}.is-size-4-fullhd{font-size:1.5rem!important}.is-size-5-fullhd{font-size:1.25rem!important}.is-size-6-fullhd{font-size:1rem!important}.is-size-7-fullhd{font-size:.75rem!important}}.has-text-centered{text-align:center!important}.has-text-justified{text-align:justify!important}.has-text-left{text-align:left!important}.has-text-right{text-align:right!important}@media screen and (max-width:768px){.has-text-centered-mobile{text-align:center!important}}@media screen and (min-width:769px),print{.has-text-centered-tablet{text-align:center!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-centered-tablet-only{text-align:center!important}}@media screen and (max-width:1023px){.has-text-centered-touch{text-align:center!important}}@media screen and (min-width:1024px){.has-text-centered-desktop{text-align:center!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-centered-desktop-only{text-align:center!important}}@media screen and (min-width:1216px){.has-text-centered-widescreen{text-align:center!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-centered-widescreen-only{text-align:center!important}}@media screen and (min-width:1408px){.has-text-centered-fullhd{text-align:center!important}}@media screen and (max-width:768px){.has-text-justified-mobile{text-align:justify!important}}@media screen and (min-width:769px),print{.has-text-justified-tablet{text-align:justify!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-justified-tablet-only{text-align:justify!important}}@media screen and (max-width:1023px){.has-text-justified-touch{text-align:justify!important}}@media screen and (min-width:1024px){.has-text-justified-desktop{text-align:justify!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-justified-desktop-only{text-align:justify!important}}@media screen and (min-width:1216px){.has-text-justified-widescreen{text-align:justify!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-justified-widescreen-only{text-align:justify!important}}@media screen and (min-width:1408px){.has-text-justified-fullhd{text-align:justify!important}}@media screen and (max-width:768px){.has-text-left-mobile{text-align:left!important}}@media screen and (min-width:769px),print{.has-text-left-tablet{text-align:left!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-left-tablet-only{text-align:left!important}}@media screen and (max-width:1023px){.has-text-left-touch{text-align:left!important}}@media screen and (min-width:1024px){.has-text-left-desktop{text-align:left!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-left-desktop-only{text-align:left!important}}@media screen and (min-width:1216px){.has-text-left-widescreen{text-align:left!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-left-widescreen-only{text-align:left!important}}@media screen and (min-width:1408px){.has-text-left-fullhd{text-align:left!important}}@media screen and (max-width:768px){.has-text-right-mobile{text-align:right!important}}@media screen and (min-width:769px),print{.has-text-right-tablet{text-align:right!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-right-tablet-only{text-align:right!important}}@media screen and (max-width:1023px){.has-text-right-touch{text-align:right!important}}@media screen and (min-width:1024px){.has-text-right-desktop{text-align:right!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-right-desktop-only{text-align:right!important}}@media screen and (min-width:1216px){.has-text-right-widescreen{text-align:right!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-right-widescreen-only{text-align:right!important}}@media screen and (min-width:1408px){.has-text-right-fullhd{text-align:right!important}}.is-capitalized{text-transform:capitalize!important}.is-lowercase{text-transform:lowercase!important}.is-uppercase{text-transform:uppercase!important}.is-italic{font-style:italic!important}.has-text-white{color:#fff!important}a.has-text-white:focus,a.has-text-white:hover{color:#e6e6e6!important}.has-background-white{background-color:#fff!important}.has-text-black{color:#0a0a0a!important}a.has-text-black:focus,a.has-text-black:hover{color:#000!important}.has-background-black{background-color:#0a0a0a!important}.has-text-light{color:#f5f5f5!important}a.has-text-light:focus,a.has-text-light:hover{color:#dbdbdb!important}.has-background-light{background-color:#f5f5f5!important}.has-text-dark{color:#363636!important}a.has-text-dark:focus,a.has-text-dark:hover{color:#1c1c1c!important}.has-background-dark{background-color:#363636!important}.has-text-primary{color:#00d1b2!important}a.has-text-primary:focus,a.has-text-primary:hover{color:#009e86!important}.has-background-primary{background-color:#00d1b2!important}.has-text-link{color:#3273dc!important}a.has-text-link:focus,a.has-text-link:hover{color:#205bbc!important}.has-background-link{background-color:#3273dc!important}.has-text-info{color:#3298dc!important}a.has-text-info:focus,a.has-text-info:hover{color:#207dbc!important}.has-background-info{background-color:#3298dc!important}.has-text-success{color:#48c774!important}a.has-text-success:focus,a.has-text-success:hover{color:#34a85c!important}.has-background-success{background-color:#48c774!important}.has-text-warning{color:#ffdd57!important}a.has-text-warning:focus,a.has-text-warning:hover{color:#ffd324!important}.has-background-warning{background-color:#ffdd57!important}.has-text-danger{color:#f14668!important}a.has-text-danger:focus,a.has-text-danger:hover{color:#ee1742!important}.has-background-danger{background-color:#f14668!important}.has-text-black-bis{color:#121212!important}.has-background-black-bis{background-color:#121212!important}.has-text-black-ter{color:#242424!important}.has-background-black-ter{background-color:#242424!important}.has-text-grey-darker{color:#363636!important}.has-background-grey-darker{background-color:#363636!important}.has-text-grey-dark{color:#4a4a4a!important}.has-background-grey-dark{background-color:#4a4a4a!important}.has-text-grey{color:#7a7a7a!important}.has-background-grey{background-color:#7a7a7a!important}.has-text-grey-light{color:#b5b5b5!important}.has-background-grey-light{background-color:#b5b5b5!important}.has-text-grey-lighter{color:#dbdbdb!important}.has-background-grey-lighter{background-color:#dbdbdb!important}.has-text-white-ter{color:#f5f5f5!important}.has-background-white-ter{background-color:#f5f5f5!important}.has-text-white-bis{color:#fafafa!important}.has-background-white-bis{background-color:#fafafa!important}.has-text-weight-light{font-weight:300!important}.has-text-weight-normal{font-weight:400!important}.has-text-weight-medium{font-weight:500!important}.has-text-weight-semibold{font-weight:600!important}.has-text-weight-bold{font-weight:700!important}.is-family-primary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-secondary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-sans-serif{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-monospace{font-family:monospace!important}.is-family-code{font-family:monospace!important}.is-block{display:block!important}@media screen and (max-width:768px){.is-block-mobile{display:block!important}}@media screen and (min-width:769px),print{.is-block-tablet{display:block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-block-tablet-only{display:block!important}}@media screen and (max-width:1023px){.is-block-touch{display:block!important}}@media screen and (min-width:1024px){.is-block-desktop{display:block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-block-desktop-only{display:block!important}}@media screen and (min-width:1216px){.is-block-widescreen{display:block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-block-widescreen-only{display:block!important}}@media screen and (min-width:1408px){.is-block-fullhd{display:block!important}}.is-flex{display:flex!important}@media screen and (max-width:768px){.is-flex-mobile{display:flex!important}}@media screen and (min-width:769px),print{.is-flex-tablet{display:flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-flex-tablet-only{display:flex!important}}@media screen and (max-width:1023px){.is-flex-touch{display:flex!important}}@media screen and (min-width:1024px){.is-flex-desktop{display:flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-flex-desktop-only{display:flex!important}}@media screen and (min-width:1216px){.is-flex-widescreen{display:flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-flex-widescreen-only{display:flex!important}}@media screen and (min-width:1408px){.is-flex-fullhd{display:flex!important}}.is-inline{display:inline!important}@media screen and (max-width:768px){.is-inline-mobile{display:inline!important}}@media screen and (min-width:769px),print{.is-inline-tablet{display:inline!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-tablet-only{display:inline!important}}@media screen and (max-width:1023px){.is-inline-touch{display:inline!important}}@media screen and (min-width:1024px){.is-inline-desktop{display:inline!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-desktop-only{display:inline!important}}@media screen and (min-width:1216px){.is-inline-widescreen{display:inline!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-widescreen-only{display:inline!important}}@media screen and (min-width:1408px){.is-inline-fullhd{display:inline!important}}.is-inline-block{display:inline-block!important}@media screen and (max-width:768px){.is-inline-block-mobile{display:inline-block!important}}@media screen and (min-width:769px),print{.is-inline-block-tablet{display:inline-block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-block-tablet-only{display:inline-block!important}}@media screen and (max-width:1023px){.is-inline-block-touch{display:inline-block!important}}@media screen and (min-width:1024px){.is-inline-block-desktop{display:inline-block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-block-desktop-only{display:inline-block!important}}@media screen and (min-width:1216px){.is-inline-block-widescreen{display:inline-block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-block-widescreen-only{display:inline-block!important}}@media screen and (min-width:1408px){.is-inline-block-fullhd{display:inline-block!important}}.is-inline-flex{display:inline-flex!important}@media screen and (max-width:768px){.is-inline-flex-mobile{display:inline-flex!important}}@media screen and (min-width:769px),print{.is-inline-flex-tablet{display:inline-flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-flex-tablet-only{display:inline-flex!important}}@media screen and (max-width:1023px){.is-inline-flex-touch{display:inline-flex!important}}@media screen and (min-width:1024px){.is-inline-flex-desktop{display:inline-flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-flex-desktop-only{display:inline-flex!important}}@media screen and (min-width:1216px){.is-inline-flex-widescreen{display:inline-flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-flex-widescreen-only{display:inline-flex!important}}@media screen and (min-width:1408px){.is-inline-flex-fullhd{display:inline-flex!important}}.is-hidden{display:none!important}.is-sr-only{border:none!important;clip:rect(0,0,0,0)!important;height:.01em!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:.01em!important}@media screen and (max-width:768px){.is-hidden-mobile{display:none!important}}@media screen and (min-width:769px),print{.is-hidden-tablet{display:none!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-hidden-tablet-only{display:none!important}}@media screen and (max-width:1023px){.is-hidden-touch{display:none!important}}@media screen and (min-width:1024px){.is-hidden-desktop{display:none!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-hidden-desktop-only{display:none!important}}@media screen and (min-width:1216px){.is-hidden-widescreen{display:none!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-hidden-widescreen-only{display:none!important}}@media screen and (min-width:1408px){.is-hidden-fullhd{display:none!important}}.is-invisible{visibility:hidden!important}@media screen and (max-width:768px){.is-invisible-mobile{visibility:hidden!important}}@media screen and (min-width:769px),print{.is-invisible-tablet{visibility:hidden!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-invisible-tablet-only{visibility:hidden!important}}@media screen and (max-width:1023px){.is-invisible-touch{visibility:hidden!important}}@media screen and (min-width:1024px){.is-invisible-desktop{visibility:hidden!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-invisible-desktop-only{visibility:hidden!important}}@media screen and (min-width:1216px){.is-invisible-widescreen{visibility:hidden!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-invisible-widescreen-only{visibility:hidden!important}}@media screen and (min-width:1408px){.is-invisible-fullhd{visibility:hidden!important}}.is-marginless{margin:0!important}.is-paddingless{padding:0!important}.is-radiusless{border-radius:0!important}.is-shadowless{box-shadow:none!important}.is-relative{position:relative!important}.box{background-color:#fff;border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;display:block;padding:1.25rem}a.box:focus,a.box:hover{box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px #3273dc}a.box:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2),0 0 0 1px #3273dc}.button{background-color:#fff;border-color:#dbdbdb;border-width:1px;color:#363636;cursor:pointer;justify-content:center;padding-bottom:calc(.5em - 1px);padding-left:1em;padding-right:1em;padding-top:calc(.5em - 1px);text-align:center;white-space:nowrap}.button strong{color:inherit}.button .icon,.button .icon.is-large,.button .icon.is-medium,.button .icon.is-small{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-left:calc(-.5em - 1px);margin-right:.25em}.button .icon:last-child:not(:first-child){margin-left:.25em;margin-right:calc(-.5em - 1px)}.button .icon:first-child:last-child{margin-left:calc(-.5em - 1px);margin-right:calc(-.5em - 1px)}.button.is-hovered,.button:hover{border-color:#b5b5b5;color:#363636}.button.is-focused,.button:focus{border-color:#3273dc;color:#363636}.button.is-focused:not(:active),.button:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-active,.button:active{border-color:#4a4a4a;color:#363636}.button.is-text{background-color:transparent;border-color:transparent;color:#4a4a4a;text-decoration:underline}.button.is-text.is-focused,.button.is-text.is-hovered,.button.is-text:focus,.button.is-text:hover{background-color:#f5f5f5;color:#363636}.button.is-text.is-active,.button.is-text:active{background-color:#e8e8e8;color:#363636}.button.is-text[disabled],fieldset[disabled] .button.is-text{background-color:transparent;border-color:transparent;box-shadow:none}.button.is-white{background-color:#fff;border-color:transparent;color:#0a0a0a}.button.is-white.is-hovered,.button.is-white:hover{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.button.is-white.is-focused,.button.is-white:focus{border-color:transparent;color:#0a0a0a}.button.is-white.is-focused:not(:active),.button.is-white:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.button.is-white.is-active,.button.is-white:active{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.button.is-white[disabled],fieldset[disabled] .button.is-white{background-color:#fff;border-color:transparent;box-shadow:none}.button.is-white.is-inverted{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-hovered,.button.is-white.is-inverted:hover{background-color:#000}.button.is-white.is-inverted[disabled],fieldset[disabled] .button.is-white.is-inverted{background-color:#0a0a0a;border-color:transparent;box-shadow:none;color:#fff}.button.is-white.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-white.is-outlined.is-focused,.button.is-white.is-outlined.is-hovered,.button.is-white.is-outlined:focus,.button.is-white.is-outlined:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.button.is-white.is-outlined.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-outlined.is-loading.is-focused::after,.button.is-white.is-outlined.is-loading.is-hovered::after,.button.is-white.is-outlined.is-loading:focus::after,.button.is-white.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined[disabled],fieldset[disabled] .button.is-white.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-white.is-inverted.is-outlined.is-focused,.button.is-white.is-inverted.is-outlined.is-hovered,.button.is-white.is-inverted.is-outlined:focus,.button.is-white.is-inverted.is-outlined:hover{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-white.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-white.is-inverted.is-outlined.is-loading:focus::after,.button.is-white.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black{background-color:#0a0a0a;border-color:transparent;color:#fff}.button.is-black.is-hovered,.button.is-black:hover{background-color:#040404;border-color:transparent;color:#fff}.button.is-black.is-focused,.button.is-black:focus{border-color:transparent;color:#fff}.button.is-black.is-focused:not(:active),.button.is-black:focus:not(:active){box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.button.is-black.is-active,.button.is-black:active{background-color:#000;border-color:transparent;color:#fff}.button.is-black[disabled],fieldset[disabled] .button.is-black{background-color:#0a0a0a;border-color:transparent;box-shadow:none}.button.is-black.is-inverted{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-hovered,.button.is-black.is-inverted:hover{background-color:#f2f2f2}.button.is-black.is-inverted[disabled],fieldset[disabled] .button.is-black.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#0a0a0a}.button.is-black.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-black.is-outlined.is-focused,.button.is-black.is-outlined.is-hovered,.button.is-black.is-outlined:focus,.button.is-black.is-outlined:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.button.is-black.is-outlined.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-outlined.is-loading.is-focused::after,.button.is-black.is-outlined.is-loading.is-hovered::after,.button.is-black.is-outlined.is-loading:focus::after,.button.is-black.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined[disabled],fieldset[disabled] .button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-black.is-inverted.is-outlined.is-focused,.button.is-black.is-inverted.is-outlined.is-hovered,.button.is-black.is-inverted.is-outlined:focus,.button.is-black.is-inverted.is-outlined:hover{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-black.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-black.is-inverted.is-outlined.is-loading:focus::after,.button.is-black.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-light{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-hovered,.button.is-light:hover{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused,.button.is-light:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused:not(:active),.button.is-light:focus:not(:active){box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.button.is-light.is-active,.button.is-light:active{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light[disabled],fieldset[disabled] .button.is-light{background-color:#f5f5f5;border-color:transparent;box-shadow:none}.button.is-light.is-inverted{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-hovered,.button.is-light.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-light.is-inverted[disabled],fieldset[disabled] .button.is-light.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#f5f5f5}.button.is-light.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;color:#f5f5f5}.button.is-light.is-outlined.is-focused,.button.is-light.is-outlined.is-hovered,.button.is-light.is-outlined:focus,.button.is-light.is-outlined:hover{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.button.is-light.is-outlined.is-loading::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-outlined.is-loading.is-focused::after,.button.is-light.is-outlined.is-loading.is-hovered::after,.button.is-light.is-outlined.is-loading:focus::after,.button.is-light.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined[disabled],fieldset[disabled] .button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;box-shadow:none;color:#f5f5f5}.button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-light.is-inverted.is-outlined.is-focused,.button.is-light.is-inverted.is-outlined.is-hovered,.button.is-light.is-inverted.is-outlined:focus,.button.is-light.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-light.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-light.is-inverted.is-outlined.is-loading:focus::after,.button.is-light.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-dark{background-color:#363636;border-color:transparent;color:#fff}.button.is-dark.is-hovered,.button.is-dark:hover{background-color:#2f2f2f;border-color:transparent;color:#fff}.button.is-dark.is-focused,.button.is-dark:focus{border-color:transparent;color:#fff}.button.is-dark.is-focused:not(:active),.button.is-dark:focus:not(:active){box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.button.is-dark.is-active,.button.is-dark:active{background-color:#292929;border-color:transparent;color:#fff}.button.is-dark[disabled],fieldset[disabled] .button.is-dark{background-color:#363636;border-color:transparent;box-shadow:none}.button.is-dark.is-inverted{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-hovered,.button.is-dark.is-inverted:hover{background-color:#f2f2f2}.button.is-dark.is-inverted[disabled],fieldset[disabled] .button.is-dark.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#363636}.button.is-dark.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined{background-color:transparent;border-color:#363636;color:#363636}.button.is-dark.is-outlined.is-focused,.button.is-dark.is-outlined.is-hovered,.button.is-dark.is-outlined:focus,.button.is-dark.is-outlined:hover{background-color:#363636;border-color:#363636;color:#fff}.button.is-dark.is-outlined.is-loading::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-outlined.is-loading.is-focused::after,.button.is-dark.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-outlined.is-loading:focus::after,.button.is-dark.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-outlined{background-color:transparent;border-color:#363636;box-shadow:none;color:#363636}.button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-dark.is-inverted.is-outlined.is-focused,.button.is-dark.is-inverted.is-outlined.is-hovered,.button.is-dark.is-inverted.is-outlined:focus,.button.is-dark.is-inverted.is-outlined:hover{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-dark.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-inverted.is-outlined.is-loading:focus::after,.button.is-dark.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary{background-color:#00d1b2;border-color:transparent;color:#fff}.button.is-primary.is-hovered,.button.is-primary:hover{background-color:#00c4a7;border-color:transparent;color:#fff}.button.is-primary.is-focused,.button.is-primary:focus{border-color:transparent;color:#fff}.button.is-primary.is-focused:not(:active),.button.is-primary:focus:not(:active){box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.button.is-primary.is-active,.button.is-primary:active{background-color:#00b89c;border-color:transparent;color:#fff}.button.is-primary[disabled],fieldset[disabled] .button.is-primary{background-color:#00d1b2;border-color:transparent;box-shadow:none}.button.is-primary.is-inverted{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-hovered,.button.is-primary.is-inverted:hover{background-color:#f2f2f2}.button.is-primary.is-inverted[disabled],fieldset[disabled] .button.is-primary.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#00d1b2}.button.is-primary.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;color:#00d1b2}.button.is-primary.is-outlined.is-focused,.button.is-primary.is-outlined.is-hovered,.button.is-primary.is-outlined:focus,.button.is-primary.is-outlined:hover{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.button.is-primary.is-outlined.is-loading::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-outlined.is-loading.is-focused::after,.button.is-primary.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-outlined.is-loading:focus::after,.button.is-primary.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;box-shadow:none;color:#00d1b2}.button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-primary.is-inverted.is-outlined.is-focused,.button.is-primary.is-inverted.is-outlined.is-hovered,.button.is-primary.is-inverted.is-outlined:focus,.button.is-primary.is-inverted.is-outlined:hover{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-primary.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-inverted.is-outlined.is-loading:focus::after,.button.is-primary.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary.is-light{background-color:#ebfffc;color:#00947e}.button.is-primary.is-light.is-hovered,.button.is-primary.is-light:hover{background-color:#defffa;border-color:transparent;color:#00947e}.button.is-primary.is-light.is-active,.button.is-primary.is-light:active{background-color:#d1fff8;border-color:transparent;color:#00947e}.button.is-link{background-color:#3273dc;border-color:transparent;color:#fff}.button.is-link.is-hovered,.button.is-link:hover{background-color:#276cda;border-color:transparent;color:#fff}.button.is-link.is-focused,.button.is-link:focus{border-color:transparent;color:#fff}.button.is-link.is-focused:not(:active),.button.is-link:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-link.is-active,.button.is-link:active{background-color:#2366d1;border-color:transparent;color:#fff}.button.is-link[disabled],fieldset[disabled] .button.is-link{background-color:#3273dc;border-color:transparent;box-shadow:none}.button.is-link.is-inverted{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-hovered,.button.is-link.is-inverted:hover{background-color:#f2f2f2}.button.is-link.is-inverted[disabled],fieldset[disabled] .button.is-link.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3273dc}.button.is-link.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;color:#3273dc}.button.is-link.is-outlined.is-focused,.button.is-link.is-outlined.is-hovered,.button.is-link.is-outlined:focus,.button.is-link.is-outlined:hover{background-color:#3273dc;border-color:#3273dc;color:#fff}.button.is-link.is-outlined.is-loading::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-outlined.is-loading.is-focused::after,.button.is-link.is-outlined.is-loading.is-hovered::after,.button.is-link.is-outlined.is-loading:focus::after,.button.is-link.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined[disabled],fieldset[disabled] .button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;box-shadow:none;color:#3273dc}.button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-link.is-inverted.is-outlined.is-focused,.button.is-link.is-inverted.is-outlined.is-hovered,.button.is-link.is-inverted.is-outlined:focus,.button.is-link.is-inverted.is-outlined:hover{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-link.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-link.is-inverted.is-outlined.is-loading:focus::after,.button.is-link.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-link.is-light{background-color:#eef3fc;color:#2160c4}.button.is-link.is-light.is-hovered,.button.is-link.is-light:hover{background-color:#e3ecfa;border-color:transparent;color:#2160c4}.button.is-link.is-light.is-active,.button.is-link.is-light:active{background-color:#d8e4f8;border-color:transparent;color:#2160c4}.button.is-info{background-color:#3298dc;border-color:transparent;color:#fff}.button.is-info.is-hovered,.button.is-info:hover{background-color:#2793da;border-color:transparent;color:#fff}.button.is-info.is-focused,.button.is-info:focus{border-color:transparent;color:#fff}.button.is-info.is-focused:not(:active),.button.is-info:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.button.is-info.is-active,.button.is-info:active{background-color:#238cd1;border-color:transparent;color:#fff}.button.is-info[disabled],fieldset[disabled] .button.is-info{background-color:#3298dc;border-color:transparent;box-shadow:none}.button.is-info.is-inverted{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-hovered,.button.is-info.is-inverted:hover{background-color:#f2f2f2}.button.is-info.is-inverted[disabled],fieldset[disabled] .button.is-info.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3298dc}.button.is-info.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;color:#3298dc}.button.is-info.is-outlined.is-focused,.button.is-info.is-outlined.is-hovered,.button.is-info.is-outlined:focus,.button.is-info.is-outlined:hover{background-color:#3298dc;border-color:#3298dc;color:#fff}.button.is-info.is-outlined.is-loading::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-outlined.is-loading.is-focused::after,.button.is-info.is-outlined.is-loading.is-hovered::after,.button.is-info.is-outlined.is-loading:focus::after,.button.is-info.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined[disabled],fieldset[disabled] .button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;box-shadow:none;color:#3298dc}.button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-info.is-inverted.is-outlined.is-focused,.button.is-info.is-inverted.is-outlined.is-hovered,.button.is-info.is-inverted.is-outlined:focus,.button.is-info.is-inverted.is-outlined:hover{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-info.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-info.is-inverted.is-outlined.is-loading:focus::after,.button.is-info.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.button.is-info.is-light.is-hovered,.button.is-info.is-light:hover{background-color:#e3f1fa;border-color:transparent;color:#1d72aa}.button.is-info.is-light.is-active,.button.is-info.is-light:active{background-color:#d8ebf8;border-color:transparent;color:#1d72aa}.button.is-success{background-color:#48c774;border-color:transparent;color:#fff}.button.is-success.is-hovered,.button.is-success:hover{background-color:#3ec46d;border-color:transparent;color:#fff}.button.is-success.is-focused,.button.is-success:focus{border-color:transparent;color:#fff}.button.is-success.is-focused:not(:active),.button.is-success:focus:not(:active){box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.button.is-success.is-active,.button.is-success:active{background-color:#3abb67;border-color:transparent;color:#fff}.button.is-success[disabled],fieldset[disabled] .button.is-success{background-color:#48c774;border-color:transparent;box-shadow:none}.button.is-success.is-inverted{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-hovered,.button.is-success.is-inverted:hover{background-color:#f2f2f2}.button.is-success.is-inverted[disabled],fieldset[disabled] .button.is-success.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#48c774}.button.is-success.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined{background-color:transparent;border-color:#48c774;color:#48c774}.button.is-success.is-outlined.is-focused,.button.is-success.is-outlined.is-hovered,.button.is-success.is-outlined:focus,.button.is-success.is-outlined:hover{background-color:#48c774;border-color:#48c774;color:#fff}.button.is-success.is-outlined.is-loading::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-outlined.is-loading.is-focused::after,.button.is-success.is-outlined.is-loading.is-hovered::after,.button.is-success.is-outlined.is-loading:focus::after,.button.is-success.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined[disabled],fieldset[disabled] .button.is-success.is-outlined{background-color:transparent;border-color:#48c774;box-shadow:none;color:#48c774}.button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-success.is-inverted.is-outlined.is-focused,.button.is-success.is-inverted.is-outlined.is-hovered,.button.is-success.is-inverted.is-outlined:focus,.button.is-success.is-inverted.is-outlined:hover{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-success.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-success.is-inverted.is-outlined.is-loading:focus::after,.button.is-success.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-success.is-light{background-color:#effaf3;color:#257942}.button.is-success.is-light.is-hovered,.button.is-success.is-light:hover{background-color:#e6f7ec;border-color:transparent;color:#257942}.button.is-success.is-light.is-active,.button.is-success.is-light:active{background-color:#dcf4e4;border-color:transparent;color:#257942}.button.is-warning{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-hovered,.button.is-warning:hover{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused,.button.is-warning:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused:not(:active),.button.is-warning:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.button.is-warning.is-active,.button.is-warning:active{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning[disabled],fieldset[disabled] .button.is-warning{background-color:#ffdd57;border-color:transparent;box-shadow:none}.button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-hovered,.button.is-warning.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-warning.is-inverted[disabled],fieldset[disabled] .button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#ffdd57}.button.is-warning.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;color:#ffdd57}.button.is-warning.is-outlined.is-focused,.button.is-warning.is-outlined.is-hovered,.button.is-warning.is-outlined:focus,.button.is-warning.is-outlined:hover{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.button.is-warning.is-outlined.is-loading::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-outlined.is-loading.is-focused::after,.button.is-warning.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-outlined.is-loading:focus::after,.button.is-warning.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;box-shadow:none;color:#ffdd57}.button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-warning.is-inverted.is-outlined.is-focused,.button.is-warning.is-inverted.is-outlined.is-hovered,.button.is-warning.is-inverted.is-outlined:focus,.button.is-warning.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-warning.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-inverted.is-outlined.is-loading:focus::after,.button.is-warning.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-warning.is-light{background-color:#fffbeb;color:#947600}.button.is-warning.is-light.is-hovered,.button.is-warning.is-light:hover{background-color:#fff8de;border-color:transparent;color:#947600}.button.is-warning.is-light.is-active,.button.is-warning.is-light:active{background-color:#fff6d1;border-color:transparent;color:#947600}.button.is-danger{background-color:#f14668;border-color:transparent;color:#fff}.button.is-danger.is-hovered,.button.is-danger:hover{background-color:#f03a5f;border-color:transparent;color:#fff}.button.is-danger.is-focused,.button.is-danger:focus{border-color:transparent;color:#fff}.button.is-danger.is-focused:not(:active),.button.is-danger:focus:not(:active){box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.button.is-danger.is-active,.button.is-danger:active{background-color:#ef2e55;border-color:transparent;color:#fff}.button.is-danger[disabled],fieldset[disabled] .button.is-danger{background-color:#f14668;border-color:transparent;box-shadow:none}.button.is-danger.is-inverted{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-hovered,.button.is-danger.is-inverted:hover{background-color:#f2f2f2}.button.is-danger.is-inverted[disabled],fieldset[disabled] .button.is-danger.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#f14668}.button.is-danger.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;color:#f14668}.button.is-danger.is-outlined.is-focused,.button.is-danger.is-outlined.is-hovered,.button.is-danger.is-outlined:focus,.button.is-danger.is-outlined:hover{background-color:#f14668;border-color:#f14668;color:#fff}.button.is-danger.is-outlined.is-loading::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-outlined.is-loading.is-focused::after,.button.is-danger.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-outlined.is-loading:focus::after,.button.is-danger.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;box-shadow:none;color:#f14668}.button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-danger.is-inverted.is-outlined.is-focused,.button.is-danger.is-inverted.is-outlined.is-hovered,.button.is-danger.is-inverted.is-outlined:focus,.button.is-danger.is-inverted.is-outlined:hover{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-danger.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-inverted.is-outlined.is-loading:focus::after,.button.is-danger.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.button.is-danger.is-light.is-hovered,.button.is-danger.is-light:hover{background-color:#fde0e6;border-color:transparent;color:#cc0f35}.button.is-danger.is-light.is-active,.button.is-danger.is-light:active{background-color:#fcd4dc;border-color:transparent;color:#cc0f35}.button.is-small{border-radius:2px;font-size:.75rem}.button.is-normal{font-size:1rem}.button.is-medium{font-size:1.25rem}.button.is-large{font-size:1.5rem}.button[disabled],fieldset[disabled] .button{background-color:#fff;border-color:#dbdbdb;box-shadow:none;opacity:.5}.button.is-fullwidth{display:flex;width:100%}.button.is-loading{color:transparent!important;pointer-events:none}.button.is-loading::after{position:absolute;left:calc(50% - (1em / 2));top:calc(50% - (1em / 2));position:absolute!important}.button.is-static{background-color:#f5f5f5;border-color:#dbdbdb;color:#7a7a7a;box-shadow:none;pointer-events:none}.button.is-rounded{border-radius:290486px;padding-left:calc(1em + .25em);padding-right:calc(1em + .25em)}.buttons{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.buttons .button{margin-bottom:.5rem}.buttons .button:not(:last-child):not(.is-fullwidth){margin-right:.5rem}.buttons:last-child{margin-bottom:-.5rem}.buttons:not(:last-child){margin-bottom:1rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large){border-radius:2px;font-size:.75rem}.buttons.are-medium .button:not(.is-small):not(.is-normal):not(.is-large){font-size:1.25rem}.buttons.are-large .button:not(.is-small):not(.is-normal):not(.is-medium){font-size:1.5rem}.buttons.has-addons .button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.buttons.has-addons .button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.buttons.has-addons .button:last-child{margin-right:0}.buttons.has-addons .button.is-hovered,.buttons.has-addons .button:hover{z-index:2}.buttons.has-addons .button.is-active,.buttons.has-addons .button.is-focused,.buttons.has-addons .button.is-selected,.buttons.has-addons .button:active,.buttons.has-addons .button:focus{z-index:3}.buttons.has-addons .button.is-active:hover,.buttons.has-addons .button.is-focused:hover,.buttons.has-addons .button.is-selected:hover,.buttons.has-addons .button:active:hover,.buttons.has-addons .button:focus:hover{z-index:4}.buttons.has-addons .button.is-expanded{flex-grow:1;flex-shrink:1}.buttons.is-centered{justify-content:center}.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.buttons.is-right{justify-content:flex-end}.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.container{flex-grow:1;margin:0 auto;position:relative;width:auto}.container.is-fluid{max-width:none;padding-left:32px;padding-right:32px;width:100%}@media screen and (min-width:1024px){.container{max-width:960px}}@media screen and (max-width:1215px){.container.is-widescreen{max-width:1152px}}@media screen and (max-width:1407px){.container.is-fullhd{max-width:1344px}}@media screen and (min-width:1216px){.container{max-width:1152px}}@media screen and (min-width:1408px){.container{max-width:1344px}}.content li+li{margin-top:.25em}.content blockquote:not(:last-child),.content dl:not(:last-child),.content ol:not(:last-child),.content p:not(:last-child),.content pre:not(:last-child),.content table:not(:last-child),.content ul:not(:last-child){margin-bottom:1em}.content h1,.content h2,.content h3,.content h4,.content h5,.content h6{color:#363636;font-weight:600;line-height:1.125}.content h1{font-size:2em;margin-bottom:.5em}.content h1:not(:first-child){margin-top:1em}.content h2{font-size:1.75em;margin-bottom:.5714em}.content h2:not(:first-child){margin-top:1.1428em}.content h3{font-size:1.5em;margin-bottom:.6666em}.content h3:not(:first-child){margin-top:1.3333em}.content h4{font-size:1.25em;margin-bottom:.8em}.content h5{font-size:1.125em;margin-bottom:.8888em}.content h6{font-size:1em;margin-bottom:1em}.content blockquote{background-color:#f5f5f5;border-left:5px solid #dbdbdb;padding:1.25em 1.5em}.content ol{list-style-position:outside;margin-left:2em;margin-top:1em}.content ol:not([type]){list-style-type:decimal}.content ol:not([type]).is-lower-alpha{list-style-type:lower-alpha}.content ol:not([type]).is-lower-roman{list-style-type:lower-roman}.content ol:not([type]).is-upper-alpha{list-style-type:upper-alpha}.content ol:not([type]).is-upper-roman{list-style-type:upper-roman}.content ul{list-style:disc outside;margin-left:2em;margin-top:1em}.content ul ul{list-style-type:circle;margin-top:.5em}.content ul ul ul{list-style-type:square}.content dd{margin-left:2em}.content figure{margin-left:2em;margin-right:2em;text-align:center}.content figure:not(:first-child){margin-top:2em}.content figure:not(:last-child){margin-bottom:2em}.content figure img{display:inline-block}.content figure figcaption{font-style:italic}.content pre{-webkit-overflow-scrolling:touch;overflow-x:auto;padding:1.25em 1.5em;white-space:pre;word-wrap:normal}.content sub,.content sup{font-size:75%}.content table{width:100%}.content table td,.content table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.content table th{color:#363636}.content table th:not([align]){text-align:left}.content table thead td,.content table thead th{border-width:0 0 2px;color:#363636}.content table tfoot td,.content table tfoot th{border-width:2px 0 0;color:#363636}.content table tbody tr:last-child td,.content table tbody tr:last-child th{border-bottom-width:0}.content .tabs li+li{margin-top:0}.content.is-small{font-size:.75rem}.content.is-medium{font-size:1.25rem}.content.is-large{font-size:1.5rem}.icon{align-items:center;display:inline-flex;justify-content:center;height:1.5rem;width:1.5rem}.icon.is-small{height:1rem;width:1rem}.icon.is-medium{height:2rem;width:2rem}.icon.is-large{height:3rem;width:3rem}.image{display:block;position:relative}.image img{display:block;height:auto;width:100%}.image img.is-rounded{border-radius:290486px}.image.is-fullwidth{width:100%}.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img{height:100%;width:100%}.image.is-1by1,.image.is-square{padding-top:100%}.image.is-5by4{padding-top:80%}.image.is-4by3{padding-top:75%}.image.is-3by2{padding-top:66.6666%}.image.is-5by3{padding-top:60%}.image.is-16by9{padding-top:56.25%}.image.is-2by1{padding-top:50%}.image.is-3by1{padding-top:33.3333%}.image.is-4by5{padding-top:125%}.image.is-3by4{padding-top:133.3333%}.image.is-2by3{padding-top:150%}.image.is-3by5{padding-top:166.6666%}.image.is-9by16{padding-top:177.7777%}.image.is-1by2{padding-top:200%}.image.is-1by3{padding-top:300%}.image.is-16x16{height:16px;width:16px}.image.is-24x24{height:24px;width:24px}.image.is-32x32{height:32px;width:32px}.image.is-48x48{height:48px;width:48px}.image.is-64x64{height:64px;width:64px}.image.is-96x96{height:96px;width:96px}.image.is-128x128{height:128px;width:128px}.notification{background-color:#f5f5f5;border-radius:4px;padding:1.25rem 2.5rem 1.25rem 1.5rem;position:relative}.notification a:not(.button):not(.dropdown-item){color:currentColor;text-decoration:underline}.notification strong{color:currentColor}.notification code,.notification pre{background:#fff}.notification pre code{background:0 0}.notification>.delete{position:absolute;right:.5rem;top:.5rem}.notification .content,.notification .subtitle,.notification .title{color:currentColor}.notification.is-white{background-color:#fff;color:#0a0a0a}.notification.is-black{background-color:#0a0a0a;color:#fff}.notification.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.notification.is-dark{background-color:#363636;color:#fff}.notification.is-primary{background-color:#00d1b2;color:#fff}.notification.is-primary.is-light{background-color:#ebfffc;color:#00947e}.notification.is-link{background-color:#3273dc;color:#fff}.notification.is-link.is-light{background-color:#eef3fc;color:#2160c4}.notification.is-info{background-color:#3298dc;color:#fff}.notification.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.notification.is-success{background-color:#48c774;color:#fff}.notification.is-success.is-light{background-color:#effaf3;color:#257942}.notification.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.notification.is-warning.is-light{background-color:#fffbeb;color:#947600}.notification.is-danger{background-color:#f14668;color:#fff}.notification.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.progress{-moz-appearance:none;-webkit-appearance:none;border:none;border-radius:290486px;display:block;height:1rem;overflow:hidden;padding:0;width:100%}.progress::-webkit-progress-bar{background-color:#ededed}.progress::-webkit-progress-value{background-color:#4a4a4a}.progress::-moz-progress-bar{background-color:#4a4a4a}.progress::-ms-fill{background-color:#4a4a4a;border:none}.progress.is-white::-webkit-progress-value{background-color:#fff}.progress.is-white::-moz-progress-bar{background-color:#fff}.progress.is-white::-ms-fill{background-color:#fff}.progress.is-white:indeterminate{background-image:linear-gradient(to right,#fff 30%,#ededed 30%)}.progress.is-black::-webkit-progress-value{background-color:#0a0a0a}.progress.is-black::-moz-progress-bar{background-color:#0a0a0a}.progress.is-black::-ms-fill{background-color:#0a0a0a}.progress.is-black:indeterminate{background-image:linear-gradient(to right,#0a0a0a 30%,#ededed 30%)}.progress.is-light::-webkit-progress-value{background-color:#f5f5f5}.progress.is-light::-moz-progress-bar{background-color:#f5f5f5}.progress.is-light::-ms-fill{background-color:#f5f5f5}.progress.is-light:indeterminate{background-image:linear-gradient(to right,#f5f5f5 30%,#ededed 30%)}.progress.is-dark::-webkit-progress-value{background-color:#363636}.progress.is-dark::-moz-progress-bar{background-color:#363636}.progress.is-dark::-ms-fill{background-color:#363636}.progress.is-dark:indeterminate{background-image:linear-gradient(to right,#363636 30%,#ededed 30%)}.progress.is-primary::-webkit-progress-value{background-color:#00d1b2}.progress.is-primary::-moz-progress-bar{background-color:#00d1b2}.progress.is-primary::-ms-fill{background-color:#00d1b2}.progress.is-primary:indeterminate{background-image:linear-gradient(to right,#00d1b2 30%,#ededed 30%)}.progress.is-link::-webkit-progress-value{background-color:#3273dc}.progress.is-link::-moz-progress-bar{background-color:#3273dc}.progress.is-link::-ms-fill{background-color:#3273dc}.progress.is-link:indeterminate{background-image:linear-gradient(to right,#3273dc 30%,#ededed 30%)}.progress.is-info::-webkit-progress-value{background-color:#3298dc}.progress.is-info::-moz-progress-bar{background-color:#3298dc}.progress.is-info::-ms-fill{background-color:#3298dc}.progress.is-info:indeterminate{background-image:linear-gradient(to right,#3298dc 30%,#ededed 30%)}.progress.is-success::-webkit-progress-value{background-color:#48c774}.progress.is-success::-moz-progress-bar{background-color:#48c774}.progress.is-success::-ms-fill{background-color:#48c774}.progress.is-success:indeterminate{background-image:linear-gradient(to right,#48c774 30%,#ededed 30%)}.progress.is-warning::-webkit-progress-value{background-color:#ffdd57}.progress.is-warning::-moz-progress-bar{background-color:#ffdd57}.progress.is-warning::-ms-fill{background-color:#ffdd57}.progress.is-warning:indeterminate{background-image:linear-gradient(to right,#ffdd57 30%,#ededed 30%)}.progress.is-danger::-webkit-progress-value{background-color:#f14668}.progress.is-danger::-moz-progress-bar{background-color:#f14668}.progress.is-danger::-ms-fill{background-color:#f14668}.progress.is-danger:indeterminate{background-image:linear-gradient(to right,#f14668 30%,#ededed 30%)}.progress:indeterminate{-webkit-animation-duration:1.5s;animation-duration:1.5s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;-webkit-animation-name:moveIndeterminate;animation-name:moveIndeterminate;-webkit-animation-timing-function:linear;animation-timing-function:linear;background-color:#ededed;background-image:linear-gradient(to right,#4a4a4a 30%,#ededed 30%);background-position:top left;background-repeat:no-repeat;background-size:150% 150%}.progress:indeterminate::-webkit-progress-bar{background-color:transparent}.progress:indeterminate::-moz-progress-bar{background-color:transparent}.progress.is-small{height:.75rem}.progress.is-medium{height:1.25rem}.progress.is-large{height:1.5rem}@-webkit-keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}.table{background-color:#fff;color:#363636}.table td,.table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.table td.is-white,.table th.is-white{background-color:#fff;border-color:#fff;color:#0a0a0a}.table td.is-black,.table th.is-black{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.table td.is-light,.table th.is-light{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.table td.is-dark,.table th.is-dark{background-color:#363636;border-color:#363636;color:#fff}.table td.is-primary,.table th.is-primary{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.table td.is-link,.table th.is-link{background-color:#3273dc;border-color:#3273dc;color:#fff}.table td.is-info,.table th.is-info{background-color:#3298dc;border-color:#3298dc;color:#fff}.table td.is-success,.table th.is-success{background-color:#48c774;border-color:#48c774;color:#fff}.table td.is-warning,.table th.is-warning{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.table td.is-danger,.table th.is-danger{background-color:#f14668;border-color:#f14668;color:#fff}.table td.is-narrow,.table th.is-narrow{white-space:nowrap;width:1%}.table td.is-selected,.table th.is-selected{background-color:#00d1b2;color:#fff}.table td.is-selected a,.table td.is-selected strong,.table th.is-selected a,.table th.is-selected strong{color:currentColor}.table th{color:#363636}.table th:not([align]){text-align:left}.table tr.is-selected{background-color:#00d1b2;color:#fff}.table tr.is-selected a,.table tr.is-selected strong{color:currentColor}.table tr.is-selected td,.table tr.is-selected th{border-color:#fff;color:currentColor}.table thead{background-color:transparent}.table thead td,.table thead th{border-width:0 0 2px;color:#363636}.table tfoot{background-color:transparent}.table tfoot td,.table tfoot th{border-width:2px 0 0;color:#363636}.table tbody{background-color:transparent}.table tbody tr:last-child td,.table tbody tr:last-child th{border-bottom-width:0}.table.is-bordered td,.table.is-bordered th{border-width:1px}.table.is-bordered tr:last-child td,.table.is-bordered tr:last-child th{border-bottom-width:1px}.table.is-fullwidth{width:100%}.table.is-hoverable tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover:nth-child(even){background-color:#f5f5f5}.table.is-narrow td,.table.is-narrow th{padding:.25em .5em}.table.is-striped tbody tr:not(.is-selected):nth-child(even){background-color:#fafafa}.table-container{-webkit-overflow-scrolling:touch;overflow:auto;overflow-y:hidden;max-width:100%}.tags{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.tags .tag{margin-bottom:.5rem}.tags .tag:not(:last-child){margin-right:.5rem}.tags:last-child{margin-bottom:-.5rem}.tags:not(:last-child){margin-bottom:1rem}.tags.are-medium .tag:not(.is-normal):not(.is-large){font-size:1rem}.tags.are-large .tag:not(.is-normal):not(.is-medium){font-size:1.25rem}.tags.is-centered{justify-content:center}.tags.is-centered .tag{margin-right:.25rem;margin-left:.25rem}.tags.is-right{justify-content:flex-end}.tags.is-right .tag:not(:first-child){margin-left:.5rem}.tags.is-right .tag:not(:last-child){margin-right:0}.tags.has-addons .tag{margin-right:0}.tags.has-addons .tag:not(:first-child){margin-left:0;border-bottom-left-radius:0;border-top-left-radius:0}.tags.has-addons .tag:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.tag:not(body){align-items:center;background-color:#f5f5f5;border-radius:4px;color:#4a4a4a;display:inline-flex;font-size:.75rem;height:2em;justify-content:center;line-height:1.5;padding-left:.75em;padding-right:.75em;white-space:nowrap}.tag:not(body) .delete{margin-left:.25rem;margin-right:-.375rem}.tag:not(body).is-white{background-color:#fff;color:#0a0a0a}.tag:not(body).is-black{background-color:#0a0a0a;color:#fff}.tag:not(body).is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.tag:not(body).is-dark{background-color:#363636;color:#fff}.tag:not(body).is-primary{background-color:#00d1b2;color:#fff}.tag:not(body).is-primary.is-light{background-color:#ebfffc;color:#00947e}.tag:not(body).is-link{background-color:#3273dc;color:#fff}.tag:not(body).is-link.is-light{background-color:#eef3fc;color:#2160c4}.tag:not(body).is-info{background-color:#3298dc;color:#fff}.tag:not(body).is-info.is-light{background-color:#eef6fc;color:#1d72aa}.tag:not(body).is-success{background-color:#48c774;color:#fff}.tag:not(body).is-success.is-light{background-color:#effaf3;color:#257942}.tag:not(body).is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.tag:not(body).is-warning.is-light{background-color:#fffbeb;color:#947600}.tag:not(body).is-danger{background-color:#f14668;color:#fff}.tag:not(body).is-danger.is-light{background-color:#feecf0;color:#cc0f35}.tag:not(body).is-normal{font-size:.75rem}.tag:not(body).is-medium{font-size:1rem}.tag:not(body).is-large{font-size:1.25rem}.tag:not(body) .icon:first-child:not(:last-child){margin-left:-.375em;margin-right:.1875em}.tag:not(body) .icon:last-child:not(:first-child){margin-left:.1875em;margin-right:-.375em}.tag:not(body) .icon:first-child:last-child{margin-left:-.375em;margin-right:-.375em}.tag:not(body).is-delete{margin-left:1px;padding:0;position:relative;width:2em}.tag:not(body).is-delete::after,.tag:not(body).is-delete::before{background-color:currentColor;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.tag:not(body).is-delete::before{height:1px;width:50%}.tag:not(body).is-delete::after{height:50%;width:1px}.tag:not(body).is-delete:focus,.tag:not(body).is-delete:hover{background-color:#e8e8e8}.tag:not(body).is-delete:active{background-color:#dbdbdb}.tag:not(body).is-rounded{border-radius:290486px}a.tag:hover{text-decoration:underline}.subtitle,.title{word-break:break-word}.subtitle em,.subtitle span,.title em,.title span{font-weight:inherit}.subtitle sub,.title sub{font-size:.75em}.subtitle sup,.title sup{font-size:.75em}.subtitle .tag,.title .tag{vertical-align:middle}.title{color:#363636;font-size:2rem;font-weight:600;line-height:1.125}.title strong{color:inherit;font-weight:inherit}.title+.highlight{margin-top:-.75rem}.title:not(.is-spaced)+.subtitle{margin-top:-1.25rem}.title.is-1{font-size:3rem}.title.is-2{font-size:2.5rem}.title.is-3{font-size:2rem}.title.is-4{font-size:1.5rem}.title.is-5{font-size:1.25rem}.title.is-6{font-size:1rem}.title.is-7{font-size:.75rem}.subtitle{color:#4a4a4a;font-size:1.25rem;font-weight:400;line-height:1.25}.subtitle strong{color:#363636;font-weight:600}.subtitle:not(.is-spaced)+.title{margin-top:-1.25rem}.subtitle.is-1{font-size:3rem}.subtitle.is-2{font-size:2.5rem}.subtitle.is-3{font-size:2rem}.subtitle.is-4{font-size:1.5rem}.subtitle.is-5{font-size:1.25rem}.subtitle.is-6{font-size:1rem}.subtitle.is-7{font-size:.75rem}.heading{display:block;font-size:11px;letter-spacing:1px;margin-bottom:5px;text-transform:uppercase}.highlight{font-weight:400;max-width:100%;overflow:hidden;padding:0}.highlight pre{overflow:auto;max-width:100%}.number{align-items:center;background-color:#f5f5f5;border-radius:290486px;display:inline-flex;font-size:1.25rem;height:2em;justify-content:center;margin-right:1.5rem;min-width:2.5em;padding:.25rem .5rem;text-align:center;vertical-align:top}.input,.select select,.textarea{background-color:#fff;border-color:#dbdbdb;border-radius:4px;color:#363636}.input::-moz-placeholder,.select select::-moz-placeholder,.textarea::-moz-placeholder{color:rgba(54,54,54,.3)}.input::-webkit-input-placeholder,.select select::-webkit-input-placeholder,.textarea::-webkit-input-placeholder{color:rgba(54,54,54,.3)}.input:-moz-placeholder,.select select:-moz-placeholder,.textarea:-moz-placeholder{color:rgba(54,54,54,.3)}.input:-ms-input-placeholder,.select select:-ms-input-placeholder,.textarea:-ms-input-placeholder{color:rgba(54,54,54,.3)}.input:hover,.is-hovered.input,.is-hovered.textarea,.select select.is-hovered,.select select:hover,.textarea:hover{border-color:#b5b5b5}.input:active,.input:focus,.is-active.input,.is-active.textarea,.is-focused.input,.is-focused.textarea,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{border-color:#3273dc;box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.input[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .input,fieldset[disabled] .select select,fieldset[disabled] .textarea{background-color:#f5f5f5;border-color:#f5f5f5;box-shadow:none;color:#7a7a7a}.input[disabled]::-moz-placeholder,.select fieldset[disabled] select::-moz-placeholder,.select select[disabled]::-moz-placeholder,.textarea[disabled]::-moz-placeholder,fieldset[disabled] .input::-moz-placeholder,fieldset[disabled] .select select::-moz-placeholder,fieldset[disabled] .textarea::-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]::-webkit-input-placeholder,.select fieldset[disabled] select::-webkit-input-placeholder,.select select[disabled]::-webkit-input-placeholder,.textarea[disabled]::-webkit-input-placeholder,fieldset[disabled] .input::-webkit-input-placeholder,fieldset[disabled] .select select::-webkit-input-placeholder,fieldset[disabled] .textarea::-webkit-input-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-moz-placeholder,.select fieldset[disabled] select:-moz-placeholder,.select select[disabled]:-moz-placeholder,.textarea[disabled]:-moz-placeholder,fieldset[disabled] .input:-moz-placeholder,fieldset[disabled] .select select:-moz-placeholder,fieldset[disabled] .textarea:-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-ms-input-placeholder,.select fieldset[disabled] select:-ms-input-placeholder,.select select[disabled]:-ms-input-placeholder,.textarea[disabled]:-ms-input-placeholder,fieldset[disabled] .input:-ms-input-placeholder,fieldset[disabled] .select select:-ms-input-placeholder,fieldset[disabled] .textarea:-ms-input-placeholder{color:rgba(122,122,122,.3)}.input,.textarea{box-shadow:inset 0 .0625em .125em rgba(10,10,10,.05);max-width:100%;width:100%}.input[readonly],.textarea[readonly]{box-shadow:none}.is-white.input,.is-white.textarea{border-color:#fff}.is-white.input:active,.is-white.input:focus,.is-white.is-active.input,.is-white.is-active.textarea,.is-white.is-focused.input,.is-white.is-focused.textarea,.is-white.textarea:active,.is-white.textarea:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.is-black.input,.is-black.textarea{border-color:#0a0a0a}.is-black.input:active,.is-black.input:focus,.is-black.is-active.input,.is-black.is-active.textarea,.is-black.is-focused.input,.is-black.is-focused.textarea,.is-black.textarea:active,.is-black.textarea:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.is-light.input,.is-light.textarea{border-color:#f5f5f5}.is-light.input:active,.is-light.input:focus,.is-light.is-active.input,.is-light.is-active.textarea,.is-light.is-focused.input,.is-light.is-focused.textarea,.is-light.textarea:active,.is-light.textarea:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.is-dark.input,.is-dark.textarea{border-color:#363636}.is-dark.input:active,.is-dark.input:focus,.is-dark.is-active.input,.is-dark.is-active.textarea,.is-dark.is-focused.input,.is-dark.is-focused.textarea,.is-dark.textarea:active,.is-dark.textarea:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.is-primary.input,.is-primary.textarea{border-color:#00d1b2}.is-primary.input:active,.is-primary.input:focus,.is-primary.is-active.input,.is-primary.is-active.textarea,.is-primary.is-focused.input,.is-primary.is-focused.textarea,.is-primary.textarea:active,.is-primary.textarea:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.is-link.input,.is-link.textarea{border-color:#3273dc}.is-link.input:active,.is-link.input:focus,.is-link.is-active.input,.is-link.is-active.textarea,.is-link.is-focused.input,.is-link.is-focused.textarea,.is-link.textarea:active,.is-link.textarea:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.is-info.input,.is-info.textarea{border-color:#3298dc}.is-info.input:active,.is-info.input:focus,.is-info.is-active.input,.is-info.is-active.textarea,.is-info.is-focused.input,.is-info.is-focused.textarea,.is-info.textarea:active,.is-info.textarea:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.is-success.input,.is-success.textarea{border-color:#48c774}.is-success.input:active,.is-success.input:focus,.is-success.is-active.input,.is-success.is-active.textarea,.is-success.is-focused.input,.is-success.is-focused.textarea,.is-success.textarea:active,.is-success.textarea:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.is-warning.input,.is-warning.textarea{border-color:#ffdd57}.is-warning.input:active,.is-warning.input:focus,.is-warning.is-active.input,.is-warning.is-active.textarea,.is-warning.is-focused.input,.is-warning.is-focused.textarea,.is-warning.textarea:active,.is-warning.textarea:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.is-danger.input,.is-danger.textarea{border-color:#f14668}.is-danger.input:active,.is-danger.input:focus,.is-danger.is-active.input,.is-danger.is-active.textarea,.is-danger.is-focused.input,.is-danger.is-focused.textarea,.is-danger.textarea:active,.is-danger.textarea:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.is-small.input,.is-small.textarea{border-radius:2px;font-size:.75rem}.is-medium.input,.is-medium.textarea{font-size:1.25rem}.is-large.input,.is-large.textarea{font-size:1.5rem}.is-fullwidth.input,.is-fullwidth.textarea{display:block;width:100%}.is-inline.input,.is-inline.textarea{display:inline;width:auto}.input.is-rounded{border-radius:290486px;padding-left:calc(calc(.75em - 1px) + .375em);padding-right:calc(calc(.75em - 1px) + .375em)}.input.is-static{background-color:transparent;border-color:transparent;box-shadow:none;padding-left:0;padding-right:0}.textarea{display:block;max-width:100%;min-width:100%;padding:calc(.75em - 1px);resize:vertical}.textarea:not([rows]){max-height:40em;min-height:8em}.textarea[rows]{height:initial}.textarea.has-fixed-size{resize:none}.checkbox,.radio{cursor:pointer;display:inline-block;line-height:1.25;position:relative}.checkbox input,.radio input{cursor:pointer}.checkbox:hover,.radio:hover{color:#363636}.checkbox[disabled],.radio[disabled],fieldset[disabled] .checkbox,fieldset[disabled] .radio{color:#7a7a7a;cursor:not-allowed}.radio+.radio{margin-left:.5em}.select{display:inline-block;max-width:100%;position:relative;vertical-align:top}.select:not(.is-multiple){height:2.5em}.select:not(.is-multiple):not(.is-loading)::after{border-color:#3273dc;right:1.125em;z-index:4}.select.is-rounded select{border-radius:290486px;padding-left:1em}.select select{cursor:pointer;display:block;font-size:1em;max-width:100%;outline:0}.select select::-ms-expand{display:none}.select select[disabled]:hover,fieldset[disabled] .select select:hover{border-color:#f5f5f5}.select select:not([multiple]){padding-right:2.5em}.select select[multiple]{height:auto;padding:0}.select select[multiple] option{padding:.5em 1em}.select:not(.is-multiple):not(.is-loading):hover::after{border-color:#363636}.select.is-white:not(:hover)::after{border-color:#fff}.select.is-white select{border-color:#fff}.select.is-white select.is-hovered,.select.is-white select:hover{border-color:#f2f2f2}.select.is-white select.is-active,.select.is-white select.is-focused,.select.is-white select:active,.select.is-white select:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.select.is-black:not(:hover)::after{border-color:#0a0a0a}.select.is-black select{border-color:#0a0a0a}.select.is-black select.is-hovered,.select.is-black select:hover{border-color:#000}.select.is-black select.is-active,.select.is-black select.is-focused,.select.is-black select:active,.select.is-black select:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.select.is-light:not(:hover)::after{border-color:#f5f5f5}.select.is-light select{border-color:#f5f5f5}.select.is-light select.is-hovered,.select.is-light select:hover{border-color:#e8e8e8}.select.is-light select.is-active,.select.is-light select.is-focused,.select.is-light select:active,.select.is-light select:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.select.is-dark:not(:hover)::after{border-color:#363636}.select.is-dark select{border-color:#363636}.select.is-dark select.is-hovered,.select.is-dark select:hover{border-color:#292929}.select.is-dark select.is-active,.select.is-dark select.is-focused,.select.is-dark select:active,.select.is-dark select:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.select.is-primary:not(:hover)::after{border-color:#00d1b2}.select.is-primary select{border-color:#00d1b2}.select.is-primary select.is-hovered,.select.is-primary select:hover{border-color:#00b89c}.select.is-primary select.is-active,.select.is-primary select.is-focused,.select.is-primary select:active,.select.is-primary select:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.select.is-link:not(:hover)::after{border-color:#3273dc}.select.is-link select{border-color:#3273dc}.select.is-link select.is-hovered,.select.is-link select:hover{border-color:#2366d1}.select.is-link select.is-active,.select.is-link select.is-focused,.select.is-link select:active,.select.is-link select:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.select.is-info:not(:hover)::after{border-color:#3298dc}.select.is-info select{border-color:#3298dc}.select.is-info select.is-hovered,.select.is-info select:hover{border-color:#238cd1}.select.is-info select.is-active,.select.is-info select.is-focused,.select.is-info select:active,.select.is-info select:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.select.is-success:not(:hover)::after{border-color:#48c774}.select.is-success select{border-color:#48c774}.select.is-success select.is-hovered,.select.is-success select:hover{border-color:#3abb67}.select.is-success select.is-active,.select.is-success select.is-focused,.select.is-success select:active,.select.is-success select:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.select.is-warning:not(:hover)::after{border-color:#ffdd57}.select.is-warning select{border-color:#ffdd57}.select.is-warning select.is-hovered,.select.is-warning select:hover{border-color:#ffd83d}.select.is-warning select.is-active,.select.is-warning select.is-focused,.select.is-warning select:active,.select.is-warning select:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.select.is-danger:not(:hover)::after{border-color:#f14668}.select.is-danger select{border-color:#f14668}.select.is-danger select.is-hovered,.select.is-danger select:hover{border-color:#ef2e55}.select.is-danger select.is-active,.select.is-danger select.is-focused,.select.is-danger select:active,.select.is-danger select:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.select.is-small{border-radius:2px;font-size:.75rem}.select.is-medium{font-size:1.25rem}.select.is-large{font-size:1.5rem}.select.is-disabled::after{border-color:#7a7a7a}.select.is-fullwidth{width:100%}.select.is-fullwidth select{width:100%}.select.is-loading::after{margin-top:0;position:absolute;right:.625em;top:.625em;transform:none}.select.is-loading.is-small:after{font-size:.75rem}.select.is-loading.is-medium:after{font-size:1.25rem}.select.is-loading.is-large:after{font-size:1.5rem}.file{align-items:stretch;display:flex;justify-content:flex-start;position:relative}.file.is-white .file-cta{background-color:#fff;border-color:transparent;color:#0a0a0a}.file.is-white.is-hovered .file-cta,.file.is-white:hover .file-cta{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.file.is-white.is-focused .file-cta,.file.is-white:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,255,255,.25);color:#0a0a0a}.file.is-white.is-active .file-cta,.file.is-white:active .file-cta{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.file.is-black .file-cta{background-color:#0a0a0a;border-color:transparent;color:#fff}.file.is-black.is-hovered .file-cta,.file.is-black:hover .file-cta{background-color:#040404;border-color:transparent;color:#fff}.file.is-black.is-focused .file-cta,.file.is-black:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(10,10,10,.25);color:#fff}.file.is-black.is-active .file-cta,.file.is-black:active .file-cta{background-color:#000;border-color:transparent;color:#fff}.file.is-light .file-cta{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-hovered .file-cta,.file.is-light:hover .file-cta{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-focused .file-cta,.file.is-light:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(245,245,245,.25);color:rgba(0,0,0,.7)}.file.is-light.is-active .file-cta,.file.is-light:active .file-cta{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-dark .file-cta{background-color:#363636;border-color:transparent;color:#fff}.file.is-dark.is-hovered .file-cta,.file.is-dark:hover .file-cta{background-color:#2f2f2f;border-color:transparent;color:#fff}.file.is-dark.is-focused .file-cta,.file.is-dark:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(54,54,54,.25);color:#fff}.file.is-dark.is-active .file-cta,.file.is-dark:active .file-cta{background-color:#292929;border-color:transparent;color:#fff}.file.is-primary .file-cta{background-color:#00d1b2;border-color:transparent;color:#fff}.file.is-primary.is-hovered .file-cta,.file.is-primary:hover .file-cta{background-color:#00c4a7;border-color:transparent;color:#fff}.file.is-primary.is-focused .file-cta,.file.is-primary:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(0,209,178,.25);color:#fff}.file.is-primary.is-active .file-cta,.file.is-primary:active .file-cta{background-color:#00b89c;border-color:transparent;color:#fff}.file.is-link .file-cta{background-color:#3273dc;border-color:transparent;color:#fff}.file.is-link.is-hovered .file-cta,.file.is-link:hover .file-cta{background-color:#276cda;border-color:transparent;color:#fff}.file.is-link.is-focused .file-cta,.file.is-link:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,115,220,.25);color:#fff}.file.is-link.is-active .file-cta,.file.is-link:active .file-cta{background-color:#2366d1;border-color:transparent;color:#fff}.file.is-info .file-cta{background-color:#3298dc;border-color:transparent;color:#fff}.file.is-info.is-hovered .file-cta,.file.is-info:hover .file-cta{background-color:#2793da;border-color:transparent;color:#fff}.file.is-info.is-focused .file-cta,.file.is-info:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,152,220,.25);color:#fff}.file.is-info.is-active .file-cta,.file.is-info:active .file-cta{background-color:#238cd1;border-color:transparent;color:#fff}.file.is-success .file-cta{background-color:#48c774;border-color:transparent;color:#fff}.file.is-success.is-hovered .file-cta,.file.is-success:hover .file-cta{background-color:#3ec46d;border-color:transparent;color:#fff}.file.is-success.is-focused .file-cta,.file.is-success:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(72,199,116,.25);color:#fff}.file.is-success.is-active .file-cta,.file.is-success:active .file-cta{background-color:#3abb67;border-color:transparent;color:#fff}.file.is-warning .file-cta{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-hovered .file-cta,.file.is-warning:hover .file-cta{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-focused .file-cta,.file.is-warning:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,221,87,.25);color:rgba(0,0,0,.7)}.file.is-warning.is-active .file-cta,.file.is-warning:active .file-cta{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-danger .file-cta{background-color:#f14668;border-color:transparent;color:#fff}.file.is-danger.is-hovered .file-cta,.file.is-danger:hover .file-cta{background-color:#f03a5f;border-color:transparent;color:#fff}.file.is-danger.is-focused .file-cta,.file.is-danger:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(241,70,104,.25);color:#fff}.file.is-danger.is-active .file-cta,.file.is-danger:active .file-cta{background-color:#ef2e55;border-color:transparent;color:#fff}.file.is-small{font-size:.75rem}.file.is-medium{font-size:1.25rem}.file.is-medium .file-icon .fa{font-size:21px}.file.is-large{font-size:1.5rem}.file.is-large .file-icon .fa{font-size:28px}.file.has-name .file-cta{border-bottom-right-radius:0;border-top-right-radius:0}.file.has-name .file-name{border-bottom-left-radius:0;border-top-left-radius:0}.file.has-name.is-empty .file-cta{border-radius:4px}.file.has-name.is-empty .file-name{display:none}.file.is-boxed .file-label{flex-direction:column}.file.is-boxed .file-cta{flex-direction:column;height:auto;padding:1em 3em}.file.is-boxed .file-name{border-width:0 1px 1px}.file.is-boxed .file-icon{height:1.5em;width:1.5em}.file.is-boxed .file-icon .fa{font-size:21px}.file.is-boxed.is-small .file-icon .fa{font-size:14px}.file.is-boxed.is-medium .file-icon .fa{font-size:28px}.file.is-boxed.is-large .file-icon .fa{font-size:35px}.file.is-boxed.has-name .file-cta{border-radius:4px 4px 0 0}.file.is-boxed.has-name .file-name{border-radius:0 0 4px 4px;border-width:0 1px 1px}.file.is-centered{justify-content:center}.file.is-fullwidth .file-label{width:100%}.file.is-fullwidth .file-name{flex-grow:1;max-width:none}.file.is-right{justify-content:flex-end}.file.is-right .file-cta{border-radius:0 4px 4px 0}.file.is-right .file-name{border-radius:4px 0 0 4px;border-width:1px 0 1px 1px;order:-1}.file-label{align-items:stretch;display:flex;cursor:pointer;justify-content:flex-start;overflow:hidden;position:relative}.file-label:hover .file-cta{background-color:#eee;color:#363636}.file-label:hover .file-name{border-color:#d5d5d5}.file-label:active .file-cta{background-color:#e8e8e8;color:#363636}.file-label:active .file-name{border-color:#cfcfcf}.file-input{height:100%;left:0;opacity:0;outline:0;position:absolute;top:0;width:100%}.file-cta,.file-name{border-color:#dbdbdb;border-radius:4px;font-size:1em;padding-left:1em;padding-right:1em;white-space:nowrap}.file-cta{background-color:#f5f5f5;color:#4a4a4a}.file-name{border-color:#dbdbdb;border-style:solid;border-width:1px 1px 1px 0;display:block;max-width:16em;overflow:hidden;text-align:left;text-overflow:ellipsis}.file-icon{align-items:center;display:flex;height:1em;justify-content:center;margin-right:.5em;width:1em}.file-icon .fa{font-size:14px}.label{color:#363636;display:block;font-size:1rem;font-weight:700}.label:not(:last-child){margin-bottom:.5em}.label.is-small{font-size:.75rem}.label.is-medium{font-size:1.25rem}.label.is-large{font-size:1.5rem}.help{display:block;font-size:.75rem;margin-top:.25rem}.help.is-white{color:#fff}.help.is-black{color:#0a0a0a}.help.is-light{color:#f5f5f5}.help.is-dark{color:#363636}.help.is-primary{color:#00d1b2}.help.is-link{color:#3273dc}.help.is-info{color:#3298dc}.help.is-success{color:#48c774}.help.is-warning{color:#ffdd57}.help.is-danger{color:#f14668}.field:not(:last-child){margin-bottom:.75rem}.field.has-addons{display:flex;justify-content:flex-start}.field.has-addons .control:not(:last-child){margin-right:-1px}.field.has-addons .control:not(:first-child):not(:last-child) .button,.field.has-addons .control:not(:first-child):not(:last-child) .input,.field.has-addons .control:not(:first-child):not(:last-child) .select select{border-radius:0}.field.has-addons .control:first-child:not(:only-child) .button,.field.has-addons .control:first-child:not(:only-child) .input,.field.has-addons .control:first-child:not(:only-child) .select select{border-bottom-right-radius:0;border-top-right-radius:0}.field.has-addons .control:last-child:not(:only-child) .button,.field.has-addons .control:last-child:not(:only-child) .input,.field.has-addons .control:last-child:not(:only-child) .select select{border-bottom-left-radius:0;border-top-left-radius:0}.field.has-addons .control .button:not([disabled]).is-hovered,.field.has-addons .control .button:not([disabled]):hover,.field.has-addons .control .input:not([disabled]).is-hovered,.field.has-addons .control .input:not([disabled]):hover,.field.has-addons .control .select select:not([disabled]).is-hovered,.field.has-addons .control .select select:not([disabled]):hover{z-index:2}.field.has-addons .control .button:not([disabled]).is-active,.field.has-addons .control .button:not([disabled]).is-focused,.field.has-addons .control .button:not([disabled]):active,.field.has-addons .control .button:not([disabled]):focus,.field.has-addons .control .input:not([disabled]).is-active,.field.has-addons .control .input:not([disabled]).is-focused,.field.has-addons .control .input:not([disabled]):active,.field.has-addons .control .input:not([disabled]):focus,.field.has-addons .control .select select:not([disabled]).is-active,.field.has-addons .control .select select:not([disabled]).is-focused,.field.has-addons .control .select select:not([disabled]):active,.field.has-addons .control .select select:not([disabled]):focus{z-index:3}.field.has-addons .control .button:not([disabled]).is-active:hover,.field.has-addons .control .button:not([disabled]).is-focused:hover,.field.has-addons .control .button:not([disabled]):active:hover,.field.has-addons .control .button:not([disabled]):focus:hover,.field.has-addons .control .input:not([disabled]).is-active:hover,.field.has-addons .control .input:not([disabled]).is-focused:hover,.field.has-addons .control .input:not([disabled]):active:hover,.field.has-addons .control .input:not([disabled]):focus:hover,.field.has-addons .control .select select:not([disabled]).is-active:hover,.field.has-addons .control .select select:not([disabled]).is-focused:hover,.field.has-addons .control .select select:not([disabled]):active:hover,.field.has-addons .control .select select:not([disabled]):focus:hover{z-index:4}.field.has-addons .control.is-expanded{flex-grow:1;flex-shrink:1}.field.has-addons.has-addons-centered{justify-content:center}.field.has-addons.has-addons-right{justify-content:flex-end}.field.has-addons.has-addons-fullwidth .control{flex-grow:1;flex-shrink:0}.field.is-grouped{display:flex;justify-content:flex-start}.field.is-grouped>.control{flex-shrink:0}.field.is-grouped>.control:not(:last-child){margin-bottom:0;margin-right:.75rem}.field.is-grouped>.control.is-expanded{flex-grow:1;flex-shrink:1}.field.is-grouped.is-grouped-centered{justify-content:center}.field.is-grouped.is-grouped-right{justify-content:flex-end}.field.is-grouped.is-grouped-multiline{flex-wrap:wrap}.field.is-grouped.is-grouped-multiline>.control:last-child,.field.is-grouped.is-grouped-multiline>.control:not(:last-child){margin-bottom:.75rem}.field.is-grouped.is-grouped-multiline:last-child{margin-bottom:-.75rem}.field.is-grouped.is-grouped-multiline:not(:last-child){margin-bottom:0}@media screen and (min-width:769px),print{.field.is-horizontal{display:flex}}.field-label .label{font-size:inherit}@media screen and (max-width:768px){.field-label{margin-bottom:.5rem}}@media screen and (min-width:769px),print{.field-label{flex-basis:0;flex-grow:1;flex-shrink:0;margin-right:1.5rem;text-align:right}.field-label.is-small{font-size:.75rem;padding-top:.375em}.field-label.is-normal{padding-top:.375em}.field-label.is-medium{font-size:1.25rem;padding-top:.375em}.field-label.is-large{font-size:1.5rem;padding-top:.375em}}.field-body .field .field{margin-bottom:0}@media screen and (min-width:769px),print{.field-body{display:flex;flex-basis:0;flex-grow:5;flex-shrink:1}.field-body .field{margin-bottom:0}.field-body>.field{flex-shrink:1}.field-body>.field:not(.is-narrow){flex-grow:1}.field-body>.field:not(:last-child){margin-right:.75rem}}.control{box-sizing:border-box;clear:both;font-size:1rem;position:relative;text-align:left}.control.has-icons-left .input:focus~.icon,.control.has-icons-left .select:focus~.icon,.control.has-icons-right .input:focus~.icon,.control.has-icons-right .select:focus~.icon{color:#4a4a4a}.control.has-icons-left .input.is-small~.icon,.control.has-icons-left .select.is-small~.icon,.control.has-icons-right .input.is-small~.icon,.control.has-icons-right .select.is-small~.icon{font-size:.75rem}.control.has-icons-left .input.is-medium~.icon,.control.has-icons-left .select.is-medium~.icon,.control.has-icons-right .input.is-medium~.icon,.control.has-icons-right .select.is-medium~.icon{font-size:1.25rem}.control.has-icons-left .input.is-large~.icon,.control.has-icons-left .select.is-large~.icon,.control.has-icons-right .input.is-large~.icon,.control.has-icons-right .select.is-large~.icon{font-size:1.5rem}.control.has-icons-left .icon,.control.has-icons-right .icon{color:#dbdbdb;height:2.5em;pointer-events:none;position:absolute;top:0;width:2.5em;z-index:4}.control.has-icons-left .input,.control.has-icons-left .select select{padding-left:2.5em}.control.has-icons-left .icon.is-left{left:0}.control.has-icons-right .input,.control.has-icons-right .select select{padding-right:2.5em}.control.has-icons-right .icon.is-right{right:0}.control.is-loading::after{position:absolute!important;right:.625em;top:.625em;z-index:4}.control.is-loading.is-small:after{font-size:.75rem}.control.is-loading.is-medium:after{font-size:1.25rem}.control.is-loading.is-large:after{font-size:1.5rem}.breadcrumb{font-size:1rem;white-space:nowrap}.breadcrumb a{align-items:center;color:#3273dc;display:flex;justify-content:center;padding:0 .75em}.breadcrumb a:hover{color:#363636}.breadcrumb li{align-items:center;display:flex}.breadcrumb li:first-child a{padding-left:0}.breadcrumb li.is-active a{color:#363636;cursor:default;pointer-events:none}.breadcrumb li+li::before{color:#b5b5b5;content:"\0002f"}.breadcrumb ol,.breadcrumb ul{align-items:flex-start;display:flex;flex-wrap:wrap;justify-content:flex-start}.breadcrumb .icon:first-child{margin-right:.5em}.breadcrumb .icon:last-child{margin-left:.5em}.breadcrumb.is-centered ol,.breadcrumb.is-centered ul{justify-content:center}.breadcrumb.is-right ol,.breadcrumb.is-right ul{justify-content:flex-end}.breadcrumb.is-small{font-size:.75rem}.breadcrumb.is-medium{font-size:1.25rem}.breadcrumb.is-large{font-size:1.5rem}.breadcrumb.has-arrow-separator li+li::before{content:"\02192"}.breadcrumb.has-bullet-separator li+li::before{content:"\02022"}.breadcrumb.has-dot-separator li+li::before{content:"\000b7"}.breadcrumb.has-succeeds-separator li+li::before{content:"\0227B"}.card{background-color:#fff;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;max-width:100%;position:relative}.card-header{background-color:transparent;align-items:stretch;box-shadow:0 .125em .25em rgba(10,10,10,.1);display:flex}.card-header-title{align-items:center;color:#363636;display:flex;flex-grow:1;font-weight:700;padding:.75rem 1rem}.card-header-title.is-centered{justify-content:center}.card-header-icon{align-items:center;cursor:pointer;display:flex;justify-content:center;padding:.75rem 1rem}.card-image{display:block;position:relative}.card-content{background-color:transparent;padding:1.5rem}.card-footer{background-color:transparent;border-top:1px solid #ededed;align-items:stretch;display:flex}.card-footer-item{align-items:center;display:flex;flex-basis:0;flex-grow:1;flex-shrink:0;justify-content:center;padding:.75rem}.card-footer-item:not(:last-child){border-right:1px solid #ededed}.card .media:not(:last-child){margin-bottom:1.5rem}.dropdown{display:inline-flex;position:relative;vertical-align:top}.dropdown.is-active .dropdown-menu,.dropdown.is-hoverable:hover .dropdown-menu{display:block}.dropdown.is-right .dropdown-menu{left:auto;right:0}.dropdown.is-up .dropdown-menu{bottom:100%;padding-bottom:4px;padding-top:initial;top:auto}.dropdown-menu{display:none;left:0;min-width:12rem;padding-top:4px;position:absolute;top:100%;z-index:20}.dropdown-content{background-color:#fff;border-radius:4px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);padding-bottom:.5rem;padding-top:.5rem}.dropdown-item{color:#4a4a4a;display:block;font-size:.875rem;line-height:1.5;padding:.375rem 1rem;position:relative}a.dropdown-item,button.dropdown-item{padding-right:3rem;text-align:left;white-space:nowrap;width:100%}a.dropdown-item:hover,button.dropdown-item:hover{background-color:#f5f5f5;color:#0a0a0a}a.dropdown-item.is-active,button.dropdown-item.is-active{background-color:#3273dc;color:#fff}.dropdown-divider{background-color:#ededed;border:none;display:block;height:1px;margin:.5rem 0}.level{align-items:center;justify-content:space-between}.level code{border-radius:4px}.level img{display:inline-block;vertical-align:top}.level.is-mobile{display:flex}.level.is-mobile .level-left,.level.is-mobile .level-right{display:flex}.level.is-mobile .level-left+.level-right{margin-top:0}.level.is-mobile .level-item:not(:last-child){margin-bottom:0;margin-right:.75rem}.level.is-mobile .level-item:not(.is-narrow){flex-grow:1}@media screen and (min-width:769px),print{.level{display:flex}.level>.level-item:not(.is-narrow){flex-grow:1}}.level-item{align-items:center;display:flex;flex-basis:auto;flex-grow:0;flex-shrink:0;justify-content:center}.level-item .subtitle,.level-item .title{margin-bottom:0}@media screen and (max-width:768px){.level-item:not(:last-child){margin-bottom:.75rem}}.level-left,.level-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.level-left .level-item.is-flexible,.level-right .level-item.is-flexible{flex-grow:1}@media screen and (min-width:769px),print{.level-left .level-item:not(:last-child),.level-right .level-item:not(:last-child){margin-right:.75rem}}.level-left{align-items:center;justify-content:flex-start}@media screen and (max-width:768px){.level-left+.level-right{margin-top:1.5rem}}@media screen and (min-width:769px),print{.level-left{display:flex}}.level-right{align-items:center;justify-content:flex-end}@media screen and (min-width:769px),print{.level-right{display:flex}}.list{background-color:#fff;border-radius:4px;box-shadow:0 2px 3px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1)}.list-item{display:block;padding:.5em 1em}.list-item:not(a){color:#4a4a4a}.list-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-item:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px}.list-item:not(:last-child){border-bottom:1px solid #dbdbdb}.list-item.is-active{background-color:#3273dc;color:#fff}a.list-item{background-color:#f5f5f5;cursor:pointer}.media{align-items:flex-start;display:flex;text-align:left}.media .content:not(:last-child){margin-bottom:.75rem}.media .media{border-top:1px solid rgba(219,219,219,.5);display:flex;padding-top:.75rem}.media .media .content:not(:last-child),.media .media .control:not(:last-child){margin-bottom:.5rem}.media .media .media{padding-top:.5rem}.media .media .media+.media{margin-top:.5rem}.media+.media{border-top:1px solid rgba(219,219,219,.5);margin-top:1rem;padding-top:1rem}.media.is-large+.media{margin-top:1.5rem;padding-top:1.5rem}.media-left,.media-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.media-left{margin-right:1rem}.media-right{margin-left:1rem}.media-content{flex-basis:auto;flex-grow:1;flex-shrink:1;text-align:left}@media screen and (max-width:768px){.media-content{overflow-x:auto}}.menu{font-size:1rem}.menu.is-small{font-size:.75rem}.menu.is-medium{font-size:1.25rem}.menu.is-large{font-size:1.5rem}.menu-list{line-height:1.25}.menu-list a{border-radius:2px;color:#4a4a4a;display:block;padding:.5em .75em}.menu-list a:hover{background-color:#f5f5f5;color:#363636}.menu-list a.is-active{background-color:#3273dc;color:#fff}.menu-list li ul{border-left:1px solid #dbdbdb;margin:.75em;padding-left:.75em}.menu-label{color:#7a7a7a;font-size:.75em;letter-spacing:.1em;text-transform:uppercase}.menu-label:not(:first-child){margin-top:1em}.menu-label:not(:last-child){margin-bottom:1em}.message{background-color:#f5f5f5;border-radius:4px;font-size:1rem}.message strong{color:currentColor}.message a:not(.button):not(.tag):not(.dropdown-item){color:currentColor;text-decoration:underline}.message.is-small{font-size:.75rem}.message.is-medium{font-size:1.25rem}.message.is-large{font-size:1.5rem}.message.is-white{background-color:#fff}.message.is-white .message-header{background-color:#fff;color:#0a0a0a}.message.is-white .message-body{border-color:#fff}.message.is-black{background-color:#fafafa}.message.is-black .message-header{background-color:#0a0a0a;color:#fff}.message.is-black .message-body{border-color:#0a0a0a}.message.is-light{background-color:#fafafa}.message.is-light .message-header{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.message.is-light .message-body{border-color:#f5f5f5}.message.is-dark{background-color:#fafafa}.message.is-dark .message-header{background-color:#363636;color:#fff}.message.is-dark .message-body{border-color:#363636}.message.is-primary{background-color:#ebfffc}.message.is-primary .message-header{background-color:#00d1b2;color:#fff}.message.is-primary .message-body{border-color:#00d1b2;color:#00947e}.message.is-link{background-color:#eef3fc}.message.is-link .message-header{background-color:#3273dc;color:#fff}.message.is-link .message-body{border-color:#3273dc;color:#2160c4}.message.is-info{background-color:#eef6fc}.message.is-info .message-header{background-color:#3298dc;color:#fff}.message.is-info .message-body{border-color:#3298dc;color:#1d72aa}.message.is-success{background-color:#effaf3}.message.is-success .message-header{background-color:#48c774;color:#fff}.message.is-success .message-body{border-color:#48c774;color:#257942}.message.is-warning{background-color:#fffbeb}.message.is-warning .message-header{background-color:#ffdd57;color:rgba(0,0,0,.7)}.message.is-warning .message-body{border-color:#ffdd57;color:#947600}.message.is-danger{background-color:#feecf0}.message.is-danger .message-header{background-color:#f14668;color:#fff}.message.is-danger .message-body{border-color:#f14668;color:#cc0f35}.message-header{align-items:center;background-color:#4a4a4a;border-radius:4px 4px 0 0;color:#fff;display:flex;font-weight:700;justify-content:space-between;line-height:1.25;padding:.75em 1em;position:relative}.message-header .delete{flex-grow:0;flex-shrink:0;margin-left:.75em}.message-header+.message-body{border-width:0;border-top-left-radius:0;border-top-right-radius:0}.message-body{border-color:#dbdbdb;border-radius:4px;border-style:solid;border-width:0 0 0 4px;color:#4a4a4a;padding:1.25em 1.5em}.message-body code,.message-body pre{background-color:#fff}.message-body pre code{background-color:transparent}.modal{align-items:center;display:none;flex-direction:column;justify-content:center;overflow:hidden;position:fixed;z-index:40}.modal.is-active{display:flex}.modal-background{background-color:rgba(10,10,10,.86)}.modal-card,.modal-content{margin:0 20px;max-height:calc(100vh - 160px);overflow:auto;position:relative;width:100%}@media screen and (min-width:769px),print{.modal-card,.modal-content{margin:0 auto;max-height:calc(100vh - 40px);width:640px}}.modal-close{background:0 0;height:40px;position:fixed;right:20px;top:20px;width:40px}.modal-card{display:flex;flex-direction:column;max-height:calc(100vh - 40px);overflow:hidden;-ms-overflow-y:visible}.modal-card-foot,.modal-card-head{align-items:center;background-color:#f5f5f5;display:flex;flex-shrink:0;justify-content:flex-start;padding:20px;position:relative}.modal-card-head{border-bottom:1px solid #dbdbdb;border-top-left-radius:6px;border-top-right-radius:6px}.modal-card-title{color:#363636;flex-grow:1;flex-shrink:0;font-size:1.5rem;line-height:1}.modal-card-foot{border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:1px solid #dbdbdb}.modal-card-foot .button:not(:last-child){margin-right:.5em}.modal-card-body{-webkit-overflow-scrolling:touch;background-color:#fff;flex-grow:1;flex-shrink:1;overflow:auto;padding:20px}.navbar{background-color:#fff;min-height:3.25rem;position:relative;z-index:30}.navbar.is-white{background-color:#fff;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link,.navbar.is-white .navbar-brand>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link.is-active,.navbar.is-white .navbar-brand .navbar-link:focus,.navbar.is-white .navbar-brand .navbar-link:hover,.navbar.is-white .navbar-brand>a.navbar-item.is-active,.navbar.is-white .navbar-brand>a.navbar-item:focus,.navbar.is-white .navbar-brand>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-burger{color:#0a0a0a}@media screen and (min-width:1024px){.navbar.is-white .navbar-end .navbar-link,.navbar.is-white .navbar-end>.navbar-item,.navbar.is-white .navbar-start .navbar-link,.navbar.is-white .navbar-start>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link.is-active,.navbar.is-white .navbar-end .navbar-link:focus,.navbar.is-white .navbar-end .navbar-link:hover,.navbar.is-white .navbar-end>a.navbar-item.is-active,.navbar.is-white .navbar-end>a.navbar-item:focus,.navbar.is-white .navbar-end>a.navbar-item:hover,.navbar.is-white .navbar-start .navbar-link.is-active,.navbar.is-white .navbar-start .navbar-link:focus,.navbar.is-white .navbar-start .navbar-link:hover,.navbar.is-white .navbar-start>a.navbar-item.is-active,.navbar.is-white .navbar-start>a.navbar-item:focus,.navbar.is-white .navbar-start>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link::after,.navbar.is-white .navbar-start .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-white .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-dropdown a.navbar-item.is-active{background-color:#fff;color:#0a0a0a}}.navbar.is-black{background-color:#0a0a0a;color:#fff}.navbar.is-black .navbar-brand .navbar-link,.navbar.is-black .navbar-brand>.navbar-item{color:#fff}.navbar.is-black .navbar-brand .navbar-link.is-active,.navbar.is-black .navbar-brand .navbar-link:focus,.navbar.is-black .navbar-brand .navbar-link:hover,.navbar.is-black .navbar-brand>a.navbar-item.is-active,.navbar.is-black .navbar-brand>a.navbar-item:focus,.navbar.is-black .navbar-brand>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-black .navbar-end .navbar-link,.navbar.is-black .navbar-end>.navbar-item,.navbar.is-black .navbar-start .navbar-link,.navbar.is-black .navbar-start>.navbar-item{color:#fff}.navbar.is-black .navbar-end .navbar-link.is-active,.navbar.is-black .navbar-end .navbar-link:focus,.navbar.is-black .navbar-end .navbar-link:hover,.navbar.is-black .navbar-end>a.navbar-item.is-active,.navbar.is-black .navbar-end>a.navbar-item:focus,.navbar.is-black .navbar-end>a.navbar-item:hover,.navbar.is-black .navbar-start .navbar-link.is-active,.navbar.is-black .navbar-start .navbar-link:focus,.navbar.is-black .navbar-start .navbar-link:hover,.navbar.is-black .navbar-start>a.navbar-item.is-active,.navbar.is-black .navbar-start>a.navbar-item:focus,.navbar.is-black .navbar-start>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-end .navbar-link::after,.navbar.is-black .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-black .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-black .navbar-item.has-dropdown:hover .navbar-link{background-color:#000;color:#fff}.navbar.is-black .navbar-dropdown a.navbar-item.is-active{background-color:#0a0a0a;color:#fff}}.navbar.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link,.navbar.is-light .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link.is-active,.navbar.is-light .navbar-brand .navbar-link:focus,.navbar.is-light .navbar-brand .navbar-link:hover,.navbar.is-light .navbar-brand>a.navbar-item.is-active,.navbar.is-light .navbar-brand>a.navbar-item:focus,.navbar.is-light .navbar-brand>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-light .navbar-end .navbar-link,.navbar.is-light .navbar-end>.navbar-item,.navbar.is-light .navbar-start .navbar-link,.navbar.is-light .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link.is-active,.navbar.is-light .navbar-end .navbar-link:focus,.navbar.is-light .navbar-end .navbar-link:hover,.navbar.is-light .navbar-end>a.navbar-item.is-active,.navbar.is-light .navbar-end>a.navbar-item:focus,.navbar.is-light .navbar-end>a.navbar-item:hover,.navbar.is-light .navbar-start .navbar-link.is-active,.navbar.is-light .navbar-start .navbar-link:focus,.navbar.is-light .navbar-start .navbar-link:hover,.navbar.is-light .navbar-start>a.navbar-item.is-active,.navbar.is-light .navbar-start>a.navbar-item:focus,.navbar.is-light .navbar-start>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link::after,.navbar.is-light .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-light .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-light .navbar-item.has-dropdown:hover .navbar-link{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:rgba(0,0,0,.7)}}.navbar.is-dark{background-color:#363636;color:#fff}.navbar.is-dark .navbar-brand .navbar-link,.navbar.is-dark .navbar-brand>.navbar-item{color:#fff}.navbar.is-dark .navbar-brand .navbar-link.is-active,.navbar.is-dark .navbar-brand .navbar-link:focus,.navbar.is-dark .navbar-brand .navbar-link:hover,.navbar.is-dark .navbar-brand>a.navbar-item.is-active,.navbar.is-dark .navbar-brand>a.navbar-item:focus,.navbar.is-dark .navbar-brand>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-dark .navbar-end .navbar-link,.navbar.is-dark .navbar-end>.navbar-item,.navbar.is-dark .navbar-start .navbar-link,.navbar.is-dark .navbar-start>.navbar-item{color:#fff}.navbar.is-dark .navbar-end .navbar-link.is-active,.navbar.is-dark .navbar-end .navbar-link:focus,.navbar.is-dark .navbar-end .navbar-link:hover,.navbar.is-dark .navbar-end>a.navbar-item.is-active,.navbar.is-dark .navbar-end>a.navbar-item:focus,.navbar.is-dark .navbar-end>a.navbar-item:hover,.navbar.is-dark .navbar-start .navbar-link.is-active,.navbar.is-dark .navbar-start .navbar-link:focus,.navbar.is-dark .navbar-start .navbar-link:hover,.navbar.is-dark .navbar-start>a.navbar-item.is-active,.navbar.is-dark .navbar-start>a.navbar-item:focus,.navbar.is-dark .navbar-start>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-end .navbar-link::after,.navbar.is-dark .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link{background-color:#292929;color:#fff}.navbar.is-dark .navbar-dropdown a.navbar-item.is-active{background-color:#363636;color:#fff}}.navbar.is-primary{background-color:#00d1b2;color:#fff}.navbar.is-primary .navbar-brand .navbar-link,.navbar.is-primary .navbar-brand>.navbar-item{color:#fff}.navbar.is-primary .navbar-brand .navbar-link.is-active,.navbar.is-primary .navbar-brand .navbar-link:focus,.navbar.is-primary .navbar-brand .navbar-link:hover,.navbar.is-primary .navbar-brand>a.navbar-item.is-active,.navbar.is-primary .navbar-brand>a.navbar-item:focus,.navbar.is-primary .navbar-brand>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-primary .navbar-end .navbar-link,.navbar.is-primary .navbar-end>.navbar-item,.navbar.is-primary .navbar-start .navbar-link,.navbar.is-primary .navbar-start>.navbar-item{color:#fff}.navbar.is-primary .navbar-end .navbar-link.is-active,.navbar.is-primary .navbar-end .navbar-link:focus,.navbar.is-primary .navbar-end .navbar-link:hover,.navbar.is-primary .navbar-end>a.navbar-item.is-active,.navbar.is-primary .navbar-end>a.navbar-item:focus,.navbar.is-primary .navbar-end>a.navbar-item:hover,.navbar.is-primary .navbar-start .navbar-link.is-active,.navbar.is-primary .navbar-start .navbar-link:focus,.navbar.is-primary .navbar-start .navbar-link:hover,.navbar.is-primary .navbar-start>a.navbar-item.is-active,.navbar.is-primary .navbar-start>a.navbar-item:focus,.navbar.is-primary .navbar-start>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-end .navbar-link::after,.navbar.is-primary .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-dropdown a.navbar-item.is-active{background-color:#00d1b2;color:#fff}}.navbar.is-link{background-color:#3273dc;color:#fff}.navbar.is-link .navbar-brand .navbar-link,.navbar.is-link .navbar-brand>.navbar-item{color:#fff}.navbar.is-link .navbar-brand .navbar-link.is-active,.navbar.is-link .navbar-brand .navbar-link:focus,.navbar.is-link .navbar-brand .navbar-link:hover,.navbar.is-link .navbar-brand>a.navbar-item.is-active,.navbar.is-link .navbar-brand>a.navbar-item:focus,.navbar.is-link .navbar-brand>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-link .navbar-end .navbar-link,.navbar.is-link .navbar-end>.navbar-item,.navbar.is-link .navbar-start .navbar-link,.navbar.is-link .navbar-start>.navbar-item{color:#fff}.navbar.is-link .navbar-end .navbar-link.is-active,.navbar.is-link .navbar-end .navbar-link:focus,.navbar.is-link .navbar-end .navbar-link:hover,.navbar.is-link .navbar-end>a.navbar-item.is-active,.navbar.is-link .navbar-end>a.navbar-item:focus,.navbar.is-link .navbar-end>a.navbar-item:hover,.navbar.is-link .navbar-start .navbar-link.is-active,.navbar.is-link .navbar-start .navbar-link:focus,.navbar.is-link .navbar-start .navbar-link:hover,.navbar.is-link .navbar-start>a.navbar-item.is-active,.navbar.is-link .navbar-start>a.navbar-item:focus,.navbar.is-link .navbar-start>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-end .navbar-link::after,.navbar.is-link .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-link .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-link .navbar-item.has-dropdown:hover .navbar-link{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-dropdown a.navbar-item.is-active{background-color:#3273dc;color:#fff}}.navbar.is-info{background-color:#3298dc;color:#fff}.navbar.is-info .navbar-brand .navbar-link,.navbar.is-info .navbar-brand>.navbar-item{color:#fff}.navbar.is-info .navbar-brand .navbar-link.is-active,.navbar.is-info .navbar-brand .navbar-link:focus,.navbar.is-info .navbar-brand .navbar-link:hover,.navbar.is-info .navbar-brand>a.navbar-item.is-active,.navbar.is-info .navbar-brand>a.navbar-item:focus,.navbar.is-info .navbar-brand>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-info .navbar-end .navbar-link,.navbar.is-info .navbar-end>.navbar-item,.navbar.is-info .navbar-start .navbar-link,.navbar.is-info .navbar-start>.navbar-item{color:#fff}.navbar.is-info .navbar-end .navbar-link.is-active,.navbar.is-info .navbar-end .navbar-link:focus,.navbar.is-info .navbar-end .navbar-link:hover,.navbar.is-info .navbar-end>a.navbar-item.is-active,.navbar.is-info .navbar-end>a.navbar-item:focus,.navbar.is-info .navbar-end>a.navbar-item:hover,.navbar.is-info .navbar-start .navbar-link.is-active,.navbar.is-info .navbar-start .navbar-link:focus,.navbar.is-info .navbar-start .navbar-link:hover,.navbar.is-info .navbar-start>a.navbar-item.is-active,.navbar.is-info .navbar-start>a.navbar-item:focus,.navbar.is-info .navbar-start>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-end .navbar-link::after,.navbar.is-info .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-info .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-info .navbar-item.has-dropdown:hover .navbar-link{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-dropdown a.navbar-item.is-active{background-color:#3298dc;color:#fff}}.navbar.is-success{background-color:#48c774;color:#fff}.navbar.is-success .navbar-brand .navbar-link,.navbar.is-success .navbar-brand>.navbar-item{color:#fff}.navbar.is-success .navbar-brand .navbar-link.is-active,.navbar.is-success .navbar-brand .navbar-link:focus,.navbar.is-success .navbar-brand .navbar-link:hover,.navbar.is-success .navbar-brand>a.navbar-item.is-active,.navbar.is-success .navbar-brand>a.navbar-item:focus,.navbar.is-success .navbar-brand>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-success .navbar-end .navbar-link,.navbar.is-success .navbar-end>.navbar-item,.navbar.is-success .navbar-start .navbar-link,.navbar.is-success .navbar-start>.navbar-item{color:#fff}.navbar.is-success .navbar-end .navbar-link.is-active,.navbar.is-success .navbar-end .navbar-link:focus,.navbar.is-success .navbar-end .navbar-link:hover,.navbar.is-success .navbar-end>a.navbar-item.is-active,.navbar.is-success .navbar-end>a.navbar-item:focus,.navbar.is-success .navbar-end>a.navbar-item:hover,.navbar.is-success .navbar-start .navbar-link.is-active,.navbar.is-success .navbar-start .navbar-link:focus,.navbar.is-success .navbar-start .navbar-link:hover,.navbar.is-success .navbar-start>a.navbar-item.is-active,.navbar.is-success .navbar-start>a.navbar-item:focus,.navbar.is-success .navbar-start>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-end .navbar-link::after,.navbar.is-success .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-success .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-success .navbar-item.has-dropdown:hover .navbar-link{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-dropdown a.navbar-item.is-active{background-color:#48c774;color:#fff}}.navbar.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link,.navbar.is-warning .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link.is-active,.navbar.is-warning .navbar-brand .navbar-link:focus,.navbar.is-warning .navbar-brand .navbar-link:hover,.navbar.is-warning .navbar-brand>a.navbar-item.is-active,.navbar.is-warning .navbar-brand>a.navbar-item:focus,.navbar.is-warning .navbar-brand>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-warning .navbar-end .navbar-link,.navbar.is-warning .navbar-end>.navbar-item,.navbar.is-warning .navbar-start .navbar-link,.navbar.is-warning .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link.is-active,.navbar.is-warning .navbar-end .navbar-link:focus,.navbar.is-warning .navbar-end .navbar-link:hover,.navbar.is-warning .navbar-end>a.navbar-item.is-active,.navbar.is-warning .navbar-end>a.navbar-item:focus,.navbar.is-warning .navbar-end>a.navbar-item:hover,.navbar.is-warning .navbar-start .navbar-link.is-active,.navbar.is-warning .navbar-start .navbar-link:focus,.navbar.is-warning .navbar-start .navbar-link:hover,.navbar.is-warning .navbar-start>a.navbar-item.is-active,.navbar.is-warning .navbar-start>a.navbar-item:focus,.navbar.is-warning .navbar-start>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link::after,.navbar.is-warning .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-dropdown a.navbar-item.is-active{background-color:#ffdd57;color:rgba(0,0,0,.7)}}.navbar.is-danger{background-color:#f14668;color:#fff}.navbar.is-danger .navbar-brand .navbar-link,.navbar.is-danger .navbar-brand>.navbar-item{color:#fff}.navbar.is-danger .navbar-brand .navbar-link.is-active,.navbar.is-danger .navbar-brand .navbar-link:focus,.navbar.is-danger .navbar-brand .navbar-link:hover,.navbar.is-danger .navbar-brand>a.navbar-item.is-active,.navbar.is-danger .navbar-brand>a.navbar-item:focus,.navbar.is-danger .navbar-brand>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-danger .navbar-end .navbar-link,.navbar.is-danger .navbar-end>.navbar-item,.navbar.is-danger .navbar-start .navbar-link,.navbar.is-danger .navbar-start>.navbar-item{color:#fff}.navbar.is-danger .navbar-end .navbar-link.is-active,.navbar.is-danger .navbar-end .navbar-link:focus,.navbar.is-danger .navbar-end .navbar-link:hover,.navbar.is-danger .navbar-end>a.navbar-item.is-active,.navbar.is-danger .navbar-end>a.navbar-item:focus,.navbar.is-danger .navbar-end>a.navbar-item:hover,.navbar.is-danger .navbar-start .navbar-link.is-active,.navbar.is-danger .navbar-start .navbar-link:focus,.navbar.is-danger .navbar-start .navbar-link:hover,.navbar.is-danger .navbar-start>a.navbar-item.is-active,.navbar.is-danger .navbar-start>a.navbar-item:focus,.navbar.is-danger .navbar-start>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-end .navbar-link::after,.navbar.is-danger .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-dropdown a.navbar-item.is-active{background-color:#f14668;color:#fff}}.navbar>.container{align-items:stretch;display:flex;min-height:3.25rem;width:100%}.navbar.has-shadow{box-shadow:0 2px 0 0 #f5f5f5}.navbar.is-fixed-bottom,.navbar.is-fixed-top{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom{bottom:0}.navbar.is-fixed-bottom.has-shadow{box-shadow:0 -2px 0 0 #f5f5f5}.navbar.is-fixed-top{top:0}body.has-navbar-fixed-top,html.has-navbar-fixed-top{padding-top:3.25rem}body.has-navbar-fixed-bottom,html.has-navbar-fixed-bottom{padding-bottom:3.25rem}.navbar-brand,.navbar-tabs{align-items:stretch;display:flex;flex-shrink:0;min-height:3.25rem}.navbar-brand a.navbar-item:focus,.navbar-brand a.navbar-item:hover{background-color:transparent}.navbar-tabs{-webkit-overflow-scrolling:touch;max-width:100vw;overflow-x:auto;overflow-y:hidden}.navbar-burger{color:#4a4a4a;cursor:pointer;display:block;height:3.25rem;position:relative;width:3.25rem;margin-left:auto}.navbar-burger span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger span:nth-child(1){top:calc(50% - 6px)}.navbar-burger span:nth-child(2){top:calc(50% - 1px)}.navbar-burger span:nth-child(3){top:calc(50% + 4px)}.navbar-burger:hover{background-color:rgba(0,0,0,.05)}.navbar-burger.is-active span:nth-child(1){transform:translateY(5px) rotate(45deg)}.navbar-burger.is-active span:nth-child(2){opacity:0}.navbar-burger.is-active span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.navbar-menu{display:none}.navbar-item,.navbar-link{color:#4a4a4a;display:block;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child,.navbar-link .icon:only-child{margin-left:-.25rem;margin-right:-.25rem}.navbar-link,a.navbar-item{cursor:pointer}.navbar-link.is-active,.navbar-link:focus,.navbar-link:focus-within,.navbar-link:hover,a.navbar-item.is-active,a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover{background-color:#fafafa;color:#3273dc}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img{max-height:1.75rem}.navbar-item.has-dropdown{padding:0}.navbar-item.is-expanded{flex-grow:1;flex-shrink:1}.navbar-item.is-tab{border-bottom:1px solid transparent;min-height:3.25rem;padding-bottom:calc(.5rem - 1px)}.navbar-item.is-tab:focus,.navbar-item.is-tab:hover{background-color:transparent;border-bottom-color:#3273dc}.navbar-item.is-tab.is-active{background-color:transparent;border-bottom-color:#3273dc;border-bottom-style:solid;border-bottom-width:3px;color:#3273dc;padding-bottom:calc(.5rem - 3px)}.navbar-content{flex-grow:1;flex-shrink:1}.navbar-link:not(.is-arrowless){padding-right:2.5em}.navbar-link:not(.is-arrowless)::after{border-color:#3273dc;margin-top:-.375em;right:1.125em}.navbar-dropdown{font-size:.875rem;padding-bottom:.5rem;padding-top:.5rem}.navbar-dropdown .navbar-item{padding-left:1.5rem;padding-right:1.5rem}.navbar-divider{background-color:#f5f5f5;border:none;display:none;height:2px;margin:.5rem 0}@media screen and (max-width:1023px){.navbar>.container{display:block}.navbar-brand .navbar-item,.navbar-tabs .navbar-item{align-items:center;display:flex}.navbar-link::after{display:none}.navbar-menu{background-color:#fff;box-shadow:0 8px 16px rgba(10,10,10,.1);padding:.5rem 0}.navbar-menu.is-active{display:block}.navbar.is-fixed-bottom-touch,.navbar.is-fixed-top-touch{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-touch{bottom:0}.navbar.is-fixed-bottom-touch.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-touch{top:0}.navbar.is-fixed-top .navbar-menu,.navbar.is-fixed-top-touch .navbar-menu{-webkit-overflow-scrolling:touch;max-height:calc(100vh - 3.25rem);overflow:auto}body.has-navbar-fixed-top-touch,html.has-navbar-fixed-top-touch{padding-top:3.25rem}body.has-navbar-fixed-bottom-touch,html.has-navbar-fixed-bottom-touch{padding-bottom:3.25rem}}@media screen and (min-width:1024px){.navbar,.navbar-end,.navbar-menu,.navbar-start{align-items:stretch;display:flex}.navbar{min-height:3.25rem}.navbar.is-spaced{padding:1rem 2rem}.navbar.is-spaced .navbar-end,.navbar.is-spaced .navbar-start{align-items:center}.navbar.is-spaced .navbar-link,.navbar.is-spaced a.navbar-item{border-radius:4px}.navbar.is-transparent .navbar-link.is-active,.navbar.is-transparent .navbar-link:focus,.navbar.is-transparent .navbar-link:hover,.navbar.is-transparent a.navbar-item.is-active,.navbar.is-transparent a.navbar-item:focus,.navbar.is-transparent a.navbar-item:hover{background-color:transparent!important}.navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus-within .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link{background-color:transparent!important}.navbar.is-transparent .navbar-dropdown a.navbar-item:focus,.navbar.is-transparent .navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar.is-transparent .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-burger{display:none}.navbar-item,.navbar-link{align-items:center;display:flex}.navbar-item.has-dropdown{align-items:stretch}.navbar-item.has-dropdown-up .navbar-link::after{transform:rotate(135deg) translate(.25em,-.25em)}.navbar-item.has-dropdown-up .navbar-dropdown{border-bottom:2px solid #dbdbdb;border-radius:6px 6px 0 0;border-top:none;bottom:100%;box-shadow:0 -8px 8px rgba(10,10,10,.1);top:auto}.navbar-item.is-active .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown{display:block}.navbar-item.is-active .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus-within .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-active .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown{opacity:1;pointer-events:auto;transform:translateY(0)}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-right:auto}.navbar-end{justify-content:flex-end;margin-left:auto}.navbar-dropdown{background-color:#fff;border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:2px solid #dbdbdb;box-shadow:0 8px 8px rgba(10,10,10,.1);display:none;font-size:.875rem;left:0;min-width:100%;position:absolute;top:100%;z-index:20}.navbar-dropdown .navbar-item{padding:.375rem 1rem;white-space:nowrap}.navbar-dropdown a.navbar-item{padding-right:3rem}.navbar-dropdown a.navbar-item:focus,.navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-dropdown{border-radius:6px;border-top:none;box-shadow:0 8px 8px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1);display:block;opacity:0;pointer-events:none;top:calc(100% + (-4px));transform:translateY(-5px);transition-duration:86ms;transition-property:opacity,transform}.navbar-dropdown.is-right{left:auto;right:0}.navbar-divider{display:block}.container>.navbar .navbar-brand,.navbar>.container .navbar-brand{margin-left:-.75rem}.container>.navbar .navbar-menu,.navbar>.container .navbar-menu{margin-right:-.75rem}.navbar.is-fixed-bottom-desktop,.navbar.is-fixed-top-desktop{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-desktop{bottom:0}.navbar.is-fixed-bottom-desktop.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-desktop{top:0}body.has-navbar-fixed-top-desktop,html.has-navbar-fixed-top-desktop{padding-top:3.25rem}body.has-navbar-fixed-bottom-desktop,html.has-navbar-fixed-bottom-desktop{padding-bottom:3.25rem}body.has-spaced-navbar-fixed-top,html.has-spaced-navbar-fixed-top{padding-top:5.25rem}body.has-spaced-navbar-fixed-bottom,html.has-spaced-navbar-fixed-bottom{padding-bottom:5.25rem}.navbar-link.is-active,a.navbar-item.is-active{color:#0a0a0a}.navbar-link.is-active:not(:focus):not(:hover),a.navbar-item.is-active:not(:focus):not(:hover){background-color:transparent}.navbar-item.has-dropdown.is-active .navbar-link,.navbar-item.has-dropdown:focus .navbar-link,.navbar-item.has-dropdown:hover .navbar-link{background-color:#fafafa}}.hero.is-fullheight-with-navbar{min-height:calc(100vh - 3.25rem)}.pagination{font-size:1rem;margin:-.25rem}.pagination.is-small{font-size:.75rem}.pagination.is-medium{font-size:1.25rem}.pagination.is-large{font-size:1.5rem}.pagination.is-rounded .pagination-next,.pagination.is-rounded .pagination-previous{padding-left:1em;padding-right:1em;border-radius:290486px}.pagination.is-rounded .pagination-link{border-radius:290486px}.pagination,.pagination-list{align-items:center;display:flex;justify-content:center;text-align:center}.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous{font-size:1em;justify-content:center;margin:.25rem;padding-left:.5em;padding-right:.5em;text-align:center}.pagination-link,.pagination-next,.pagination-previous{border-color:#dbdbdb;color:#363636;min-width:2.5em}.pagination-link:hover,.pagination-next:hover,.pagination-previous:hover{border-color:#b5b5b5;color:#363636}.pagination-link:focus,.pagination-next:focus,.pagination-previous:focus{border-color:#3273dc}.pagination-link:active,.pagination-next:active,.pagination-previous:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2)}.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled]{background-color:#dbdbdb;border-color:#dbdbdb;box-shadow:none;color:#7a7a7a;opacity:.5}.pagination-next,.pagination-previous{padding-left:.75em;padding-right:.75em;white-space:nowrap}.pagination-link.is-current{background-color:#3273dc;border-color:#3273dc;color:#fff}.pagination-ellipsis{color:#b5b5b5;pointer-events:none}.pagination-list{flex-wrap:wrap}@media screen and (max-width:768px){.pagination{flex-wrap:wrap}.pagination-next,.pagination-previous{flex-grow:1;flex-shrink:1}.pagination-list li{flex-grow:1;flex-shrink:1}}@media screen and (min-width:769px),print{.pagination-list{flex-grow:1;flex-shrink:1;justify-content:flex-start;order:1}.pagination-previous{order:2}.pagination-next{order:3}.pagination{justify-content:space-between}.pagination.is-centered .pagination-previous{order:1}.pagination.is-centered .pagination-list{justify-content:center;order:2}.pagination.is-centered .pagination-next{order:3}.pagination.is-right .pagination-previous{order:1}.pagination.is-right .pagination-next{order:2}.pagination.is-right .pagination-list{justify-content:flex-end;order:3}}.panel{border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);font-size:1rem}.panel:not(:last-child){margin-bottom:1.5rem}.panel.is-white .panel-heading{background-color:#fff;color:#0a0a0a}.panel.is-white .panel-tabs a.is-active{border-bottom-color:#fff}.panel.is-white .panel-block.is-active .panel-icon{color:#fff}.panel.is-black .panel-heading{background-color:#0a0a0a;color:#fff}.panel.is-black .panel-tabs a.is-active{border-bottom-color:#0a0a0a}.panel.is-black .panel-block.is-active .panel-icon{color:#0a0a0a}.panel.is-light .panel-heading{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.panel.is-light .panel-tabs a.is-active{border-bottom-color:#f5f5f5}.panel.is-light .panel-block.is-active .panel-icon{color:#f5f5f5}.panel.is-dark .panel-heading{background-color:#363636;color:#fff}.panel.is-dark .panel-tabs a.is-active{border-bottom-color:#363636}.panel.is-dark .panel-block.is-active .panel-icon{color:#363636}.panel.is-primary .panel-heading{background-color:#00d1b2;color:#fff}.panel.is-primary .panel-tabs a.is-active{border-bottom-color:#00d1b2}.panel.is-primary .panel-block.is-active .panel-icon{color:#00d1b2}.panel.is-link .panel-heading{background-color:#3273dc;color:#fff}.panel.is-link .panel-tabs a.is-active{border-bottom-color:#3273dc}.panel.is-link .panel-block.is-active .panel-icon{color:#3273dc}.panel.is-info .panel-heading{background-color:#3298dc;color:#fff}.panel.is-info .panel-tabs a.is-active{border-bottom-color:#3298dc}.panel.is-info .panel-block.is-active .panel-icon{color:#3298dc}.panel.is-success .panel-heading{background-color:#48c774;color:#fff}.panel.is-success .panel-tabs a.is-active{border-bottom-color:#48c774}.panel.is-success .panel-block.is-active .panel-icon{color:#48c774}.panel.is-warning .panel-heading{background-color:#ffdd57;color:rgba(0,0,0,.7)}.panel.is-warning .panel-tabs a.is-active{border-bottom-color:#ffdd57}.panel.is-warning .panel-block.is-active .panel-icon{color:#ffdd57}.panel.is-danger .panel-heading{background-color:#f14668;color:#fff}.panel.is-danger .panel-tabs a.is-active{border-bottom-color:#f14668}.panel.is-danger .panel-block.is-active .panel-icon{color:#f14668}.panel-block:not(:last-child),.panel-tabs:not(:last-child){border-bottom:1px solid #ededed}.panel-heading{background-color:#ededed;border-radius:6px 6px 0 0;color:#363636;font-size:1.25em;font-weight:700;line-height:1.25;padding:.75em 1em}.panel-tabs{align-items:flex-end;display:flex;font-size:.875em;justify-content:center}.panel-tabs a{border-bottom:1px solid #dbdbdb;margin-bottom:-1px;padding:.5em}.panel-tabs a.is-active{border-bottom-color:#4a4a4a;color:#363636}.panel-list a{color:#4a4a4a}.panel-list a:hover{color:#3273dc}.panel-block{align-items:center;color:#363636;display:flex;justify-content:flex-start;padding:.5em .75em}.panel-block input[type=checkbox]{margin-right:.75em}.panel-block>.control{flex-grow:1;flex-shrink:1;width:100%}.panel-block.is-wrapped{flex-wrap:wrap}.panel-block.is-active{border-left-color:#3273dc;color:#363636}.panel-block.is-active .panel-icon{color:#3273dc}.panel-block:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}a.panel-block,label.panel-block{cursor:pointer}a.panel-block:hover,label.panel-block:hover{background-color:#f5f5f5}.panel-icon{display:inline-block;font-size:14px;height:1em;line-height:1em;text-align:center;vertical-align:top;width:1em;color:#7a7a7a;margin-right:.75em}.panel-icon .fa{font-size:inherit;line-height:inherit}.tabs{-webkit-overflow-scrolling:touch;align-items:stretch;display:flex;font-size:1rem;justify-content:space-between;overflow:hidden;overflow-x:auto;white-space:nowrap}.tabs a{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;color:#4a4a4a;display:flex;justify-content:center;margin-bottom:-1px;padding:.5em 1em;vertical-align:top}.tabs a:hover{border-bottom-color:#363636;color:#363636}.tabs li{display:block}.tabs li.is-active a{border-bottom-color:#3273dc;color:#3273dc}.tabs ul{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;display:flex;flex-grow:1;flex-shrink:0;justify-content:flex-start}.tabs ul.is-left{padding-right:.75em}.tabs ul.is-center{flex:none;justify-content:center;padding-left:.75em;padding-right:.75em}.tabs ul.is-right{justify-content:flex-end;padding-left:.75em}.tabs .icon:first-child{margin-right:.5em}.tabs .icon:last-child{margin-left:.5em}.tabs.is-centered ul{justify-content:center}.tabs.is-right ul{justify-content:flex-end}.tabs.is-boxed a{border:1px solid transparent;border-radius:4px 4px 0 0}.tabs.is-boxed a:hover{background-color:#f5f5f5;border-bottom-color:#dbdbdb}.tabs.is-boxed li.is-active a{background-color:#fff;border-color:#dbdbdb;border-bottom-color:transparent!important}.tabs.is-fullwidth li{flex-grow:1;flex-shrink:0}.tabs.is-toggle a{border-color:#dbdbdb;border-style:solid;border-width:1px;margin-bottom:0;position:relative}.tabs.is-toggle a:hover{background-color:#f5f5f5;border-color:#b5b5b5;z-index:2}.tabs.is-toggle li+li{margin-left:-1px}.tabs.is-toggle li:first-child a{border-radius:4px 0 0 4px}.tabs.is-toggle li:last-child a{border-radius:0 4px 4px 0}.tabs.is-toggle li.is-active a{background-color:#3273dc;border-color:#3273dc;color:#fff;z-index:1}.tabs.is-toggle ul{border-bottom:none}.tabs.is-toggle.is-toggle-rounded li:first-child a{border-bottom-left-radius:290486px;border-top-left-radius:290486px;padding-left:1.25em}.tabs.is-toggle.is-toggle-rounded li:last-child a{border-bottom-right-radius:290486px;border-top-right-radius:290486px;padding-right:1.25em}.tabs.is-small{font-size:.75rem}.tabs.is-medium{font-size:1.25rem}.tabs.is-large{font-size:1.5rem}.column{display:block;flex-basis:0;flex-grow:1;flex-shrink:1;padding:.75rem}.columns.is-mobile>.column.is-narrow{flex:none}.columns.is-mobile>.column.is-full{flex:none;width:100%}.columns.is-mobile>.column.is-three-quarters{flex:none;width:75%}.columns.is-mobile>.column.is-two-thirds{flex:none;width:66.6666%}.columns.is-mobile>.column.is-half{flex:none;width:50%}.columns.is-mobile>.column.is-one-third{flex:none;width:33.3333%}.columns.is-mobile>.column.is-one-quarter{flex:none;width:25%}.columns.is-mobile>.column.is-one-fifth{flex:none;width:20%}.columns.is-mobile>.column.is-two-fifths{flex:none;width:40%}.columns.is-mobile>.column.is-three-fifths{flex:none;width:60%}.columns.is-mobile>.column.is-four-fifths{flex:none;width:80%}.columns.is-mobile>.column.is-offset-three-quarters{margin-left:75%}.columns.is-mobile>.column.is-offset-two-thirds{margin-left:66.6666%}.columns.is-mobile>.column.is-offset-half{margin-left:50%}.columns.is-mobile>.column.is-offset-one-third{margin-left:33.3333%}.columns.is-mobile>.column.is-offset-one-quarter{margin-left:25%}.columns.is-mobile>.column.is-offset-one-fifth{margin-left:20%}.columns.is-mobile>.column.is-offset-two-fifths{margin-left:40%}.columns.is-mobile>.column.is-offset-three-fifths{margin-left:60%}.columns.is-mobile>.column.is-offset-four-fifths{margin-left:80%}.columns.is-mobile>.column.is-0{flex:none;width:0%}.columns.is-mobile>.column.is-offset-0{margin-left:0}.columns.is-mobile>.column.is-1{flex:none;width:8.33333%}.columns.is-mobile>.column.is-offset-1{margin-left:8.33333%}.columns.is-mobile>.column.is-2{flex:none;width:16.66667%}.columns.is-mobile>.column.is-offset-2{margin-left:16.66667%}.columns.is-mobile>.column.is-3{flex:none;width:25%}.columns.is-mobile>.column.is-offset-3{margin-left:25%}.columns.is-mobile>.column.is-4{flex:none;width:33.33333%}.columns.is-mobile>.column.is-offset-4{margin-left:33.33333%}.columns.is-mobile>.column.is-5{flex:none;width:41.66667%}.columns.is-mobile>.column.is-offset-5{margin-left:41.66667%}.columns.is-mobile>.column.is-6{flex:none;width:50%}.columns.is-mobile>.column.is-offset-6{margin-left:50%}.columns.is-mobile>.column.is-7{flex:none;width:58.33333%}.columns.is-mobile>.column.is-offset-7{margin-left:58.33333%}.columns.is-mobile>.column.is-8{flex:none;width:66.66667%}.columns.is-mobile>.column.is-offset-8{margin-left:66.66667%}.columns.is-mobile>.column.is-9{flex:none;width:75%}.columns.is-mobile>.column.is-offset-9{margin-left:75%}.columns.is-mobile>.column.is-10{flex:none;width:83.33333%}.columns.is-mobile>.column.is-offset-10{margin-left:83.33333%}.columns.is-mobile>.column.is-11{flex:none;width:91.66667%}.columns.is-mobile>.column.is-offset-11{margin-left:91.66667%}.columns.is-mobile>.column.is-12{flex:none;width:100%}.columns.is-mobile>.column.is-offset-12{margin-left:100%}@media screen and (max-width:768px){.column.is-narrow-mobile{flex:none}.column.is-full-mobile{flex:none;width:100%}.column.is-three-quarters-mobile{flex:none;width:75%}.column.is-two-thirds-mobile{flex:none;width:66.6666%}.column.is-half-mobile{flex:none;width:50%}.column.is-one-third-mobile{flex:none;width:33.3333%}.column.is-one-quarter-mobile{flex:none;width:25%}.column.is-one-fifth-mobile{flex:none;width:20%}.column.is-two-fifths-mobile{flex:none;width:40%}.column.is-three-fifths-mobile{flex:none;width:60%}.column.is-four-fifths-mobile{flex:none;width:80%}.column.is-offset-three-quarters-mobile{margin-left:75%}.column.is-offset-two-thirds-mobile{margin-left:66.6666%}.column.is-offset-half-mobile{margin-left:50%}.column.is-offset-one-third-mobile{margin-left:33.3333%}.column.is-offset-one-quarter-mobile{margin-left:25%}.column.is-offset-one-fifth-mobile{margin-left:20%}.column.is-offset-two-fifths-mobile{margin-left:40%}.column.is-offset-three-fifths-mobile{margin-left:60%}.column.is-offset-four-fifths-mobile{margin-left:80%}.column.is-0-mobile{flex:none;width:0%}.column.is-offset-0-mobile{margin-left:0}.column.is-1-mobile{flex:none;width:8.33333%}.column.is-offset-1-mobile{margin-left:8.33333%}.column.is-2-mobile{flex:none;width:16.66667%}.column.is-offset-2-mobile{margin-left:16.66667%}.column.is-3-mobile{flex:none;width:25%}.column.is-offset-3-mobile{margin-left:25%}.column.is-4-mobile{flex:none;width:33.33333%}.column.is-offset-4-mobile{margin-left:33.33333%}.column.is-5-mobile{flex:none;width:41.66667%}.column.is-offset-5-mobile{margin-left:41.66667%}.column.is-6-mobile{flex:none;width:50%}.column.is-offset-6-mobile{margin-left:50%}.column.is-7-mobile{flex:none;width:58.33333%}.column.is-offset-7-mobile{margin-left:58.33333%}.column.is-8-mobile{flex:none;width:66.66667%}.column.is-offset-8-mobile{margin-left:66.66667%}.column.is-9-mobile{flex:none;width:75%}.column.is-offset-9-mobile{margin-left:75%}.column.is-10-mobile{flex:none;width:83.33333%}.column.is-offset-10-mobile{margin-left:83.33333%}.column.is-11-mobile{flex:none;width:91.66667%}.column.is-offset-11-mobile{margin-left:91.66667%}.column.is-12-mobile{flex:none;width:100%}.column.is-offset-12-mobile{margin-left:100%}}@media screen and (min-width:769px),print{.column.is-narrow,.column.is-narrow-tablet{flex:none}.column.is-full,.column.is-full-tablet{flex:none;width:100%}.column.is-three-quarters,.column.is-three-quarters-tablet{flex:none;width:75%}.column.is-two-thirds,.column.is-two-thirds-tablet{flex:none;width:66.6666%}.column.is-half,.column.is-half-tablet{flex:none;width:50%}.column.is-one-third,.column.is-one-third-tablet{flex:none;width:33.3333%}.column.is-one-quarter,.column.is-one-quarter-tablet{flex:none;width:25%}.column.is-one-fifth,.column.is-one-fifth-tablet{flex:none;width:20%}.column.is-two-fifths,.column.is-two-fifths-tablet{flex:none;width:40%}.column.is-three-fifths,.column.is-three-fifths-tablet{flex:none;width:60%}.column.is-four-fifths,.column.is-four-fifths-tablet{flex:none;width:80%}.column.is-offset-three-quarters,.column.is-offset-three-quarters-tablet{margin-left:75%}.column.is-offset-two-thirds,.column.is-offset-two-thirds-tablet{margin-left:66.6666%}.column.is-offset-half,.column.is-offset-half-tablet{margin-left:50%}.column.is-offset-one-third,.column.is-offset-one-third-tablet{margin-left:33.3333%}.column.is-offset-one-quarter,.column.is-offset-one-quarter-tablet{margin-left:25%}.column.is-offset-one-fifth,.column.is-offset-one-fifth-tablet{margin-left:20%}.column.is-offset-two-fifths,.column.is-offset-two-fifths-tablet{margin-left:40%}.column.is-offset-three-fifths,.column.is-offset-three-fifths-tablet{margin-left:60%}.column.is-offset-four-fifths,.column.is-offset-four-fifths-tablet{margin-left:80%}.column.is-0,.column.is-0-tablet{flex:none;width:0%}.column.is-offset-0,.column.is-offset-0-tablet{margin-left:0}.column.is-1,.column.is-1-tablet{flex:none;width:8.33333%}.column.is-offset-1,.column.is-offset-1-tablet{margin-left:8.33333%}.column.is-2,.column.is-2-tablet{flex:none;width:16.66667%}.column.is-offset-2,.column.is-offset-2-tablet{margin-left:16.66667%}.column.is-3,.column.is-3-tablet{flex:none;width:25%}.column.is-offset-3,.column.is-offset-3-tablet{margin-left:25%}.column.is-4,.column.is-4-tablet{flex:none;width:33.33333%}.column.is-offset-4,.column.is-offset-4-tablet{margin-left:33.33333%}.column.is-5,.column.is-5-tablet{flex:none;width:41.66667%}.column.is-offset-5,.column.is-offset-5-tablet{margin-left:41.66667%}.column.is-6,.column.is-6-tablet{flex:none;width:50%}.column.is-offset-6,.column.is-offset-6-tablet{margin-left:50%}.column.is-7,.column.is-7-tablet{flex:none;width:58.33333%}.column.is-offset-7,.column.is-offset-7-tablet{margin-left:58.33333%}.column.is-8,.column.is-8-tablet{flex:none;width:66.66667%}.column.is-offset-8,.column.is-offset-8-tablet{margin-left:66.66667%}.column.is-9,.column.is-9-tablet{flex:none;width:75%}.column.is-offset-9,.column.is-offset-9-tablet{margin-left:75%}.column.is-10,.column.is-10-tablet{flex:none;width:83.33333%}.column.is-offset-10,.column.is-offset-10-tablet{margin-left:83.33333%}.column.is-11,.column.is-11-tablet{flex:none;width:91.66667%}.column.is-offset-11,.column.is-offset-11-tablet{margin-left:91.66667%}.column.is-12,.column.is-12-tablet{flex:none;width:100%}.column.is-offset-12,.column.is-offset-12-tablet{margin-left:100%}}@media screen and (max-width:1023px){.column.is-narrow-touch{flex:none}.column.is-full-touch{flex:none;width:100%}.column.is-three-quarters-touch{flex:none;width:75%}.column.is-two-thirds-touch{flex:none;width:66.6666%}.column.is-half-touch{flex:none;width:50%}.column.is-one-third-touch{flex:none;width:33.3333%}.column.is-one-quarter-touch{flex:none;width:25%}.column.is-one-fifth-touch{flex:none;width:20%}.column.is-two-fifths-touch{flex:none;width:40%}.column.is-three-fifths-touch{flex:none;width:60%}.column.is-four-fifths-touch{flex:none;width:80%}.column.is-offset-three-quarters-touch{margin-left:75%}.column.is-offset-two-thirds-touch{margin-left:66.6666%}.column.is-offset-half-touch{margin-left:50%}.column.is-offset-one-third-touch{margin-left:33.3333%}.column.is-offset-one-quarter-touch{margin-left:25%}.column.is-offset-one-fifth-touch{margin-left:20%}.column.is-offset-two-fifths-touch{margin-left:40%}.column.is-offset-three-fifths-touch{margin-left:60%}.column.is-offset-four-fifths-touch{margin-left:80%}.column.is-0-touch{flex:none;width:0%}.column.is-offset-0-touch{margin-left:0}.column.is-1-touch{flex:none;width:8.33333%}.column.is-offset-1-touch{margin-left:8.33333%}.column.is-2-touch{flex:none;width:16.66667%}.column.is-offset-2-touch{margin-left:16.66667%}.column.is-3-touch{flex:none;width:25%}.column.is-offset-3-touch{margin-left:25%}.column.is-4-touch{flex:none;width:33.33333%}.column.is-offset-4-touch{margin-left:33.33333%}.column.is-5-touch{flex:none;width:41.66667%}.column.is-offset-5-touch{margin-left:41.66667%}.column.is-6-touch{flex:none;width:50%}.column.is-offset-6-touch{margin-left:50%}.column.is-7-touch{flex:none;width:58.33333%}.column.is-offset-7-touch{margin-left:58.33333%}.column.is-8-touch{flex:none;width:66.66667%}.column.is-offset-8-touch{margin-left:66.66667%}.column.is-9-touch{flex:none;width:75%}.column.is-offset-9-touch{margin-left:75%}.column.is-10-touch{flex:none;width:83.33333%}.column.is-offset-10-touch{margin-left:83.33333%}.column.is-11-touch{flex:none;width:91.66667%}.column.is-offset-11-touch{margin-left:91.66667%}.column.is-12-touch{flex:none;width:100%}.column.is-offset-12-touch{margin-left:100%}}@media screen and (min-width:1024px){.column.is-narrow-desktop{flex:none}.column.is-full-desktop{flex:none;width:100%}.column.is-three-quarters-desktop{flex:none;width:75%}.column.is-two-thirds-desktop{flex:none;width:66.6666%}.column.is-half-desktop{flex:none;width:50%}.column.is-one-third-desktop{flex:none;width:33.3333%}.column.is-one-quarter-desktop{flex:none;width:25%}.column.is-one-fifth-desktop{flex:none;width:20%}.column.is-two-fifths-desktop{flex:none;width:40%}.column.is-three-fifths-desktop{flex:none;width:60%}.column.is-four-fifths-desktop{flex:none;width:80%}.column.is-offset-three-quarters-desktop{margin-left:75%}.column.is-offset-two-thirds-desktop{margin-left:66.6666%}.column.is-offset-half-desktop{margin-left:50%}.column.is-offset-one-third-desktop{margin-left:33.3333%}.column.is-offset-one-quarter-desktop{margin-left:25%}.column.is-offset-one-fifth-desktop{margin-left:20%}.column.is-offset-two-fifths-desktop{margin-left:40%}.column.is-offset-three-fifths-desktop{margin-left:60%}.column.is-offset-four-fifths-desktop{margin-left:80%}.column.is-0-desktop{flex:none;width:0%}.column.is-offset-0-desktop{margin-left:0}.column.is-1-desktop{flex:none;width:8.33333%}.column.is-offset-1-desktop{margin-left:8.33333%}.column.is-2-desktop{flex:none;width:16.66667%}.column.is-offset-2-desktop{margin-left:16.66667%}.column.is-3-desktop{flex:none;width:25%}.column.is-offset-3-desktop{margin-left:25%}.column.is-4-desktop{flex:none;width:33.33333%}.column.is-offset-4-desktop{margin-left:33.33333%}.column.is-5-desktop{flex:none;width:41.66667%}.column.is-offset-5-desktop{margin-left:41.66667%}.column.is-6-desktop{flex:none;width:50%}.column.is-offset-6-desktop{margin-left:50%}.column.is-7-desktop{flex:none;width:58.33333%}.column.is-offset-7-desktop{margin-left:58.33333%}.column.is-8-desktop{flex:none;width:66.66667%}.column.is-offset-8-desktop{margin-left:66.66667%}.column.is-9-desktop{flex:none;width:75%}.column.is-offset-9-desktop{margin-left:75%}.column.is-10-desktop{flex:none;width:83.33333%}.column.is-offset-10-desktop{margin-left:83.33333%}.column.is-11-desktop{flex:none;width:91.66667%}.column.is-offset-11-desktop{margin-left:91.66667%}.column.is-12-desktop{flex:none;width:100%}.column.is-offset-12-desktop{margin-left:100%}}@media screen and (min-width:1216px){.column.is-narrow-widescreen{flex:none}.column.is-full-widescreen{flex:none;width:100%}.column.is-three-quarters-widescreen{flex:none;width:75%}.column.is-two-thirds-widescreen{flex:none;width:66.6666%}.column.is-half-widescreen{flex:none;width:50%}.column.is-one-third-widescreen{flex:none;width:33.3333%}.column.is-one-quarter-widescreen{flex:none;width:25%}.column.is-one-fifth-widescreen{flex:none;width:20%}.column.is-two-fifths-widescreen{flex:none;width:40%}.column.is-three-fifths-widescreen{flex:none;width:60%}.column.is-four-fifths-widescreen{flex:none;width:80%}.column.is-offset-three-quarters-widescreen{margin-left:75%}.column.is-offset-two-thirds-widescreen{margin-left:66.6666%}.column.is-offset-half-widescreen{margin-left:50%}.column.is-offset-one-third-widescreen{margin-left:33.3333%}.column.is-offset-one-quarter-widescreen{margin-left:25%}.column.is-offset-one-fifth-widescreen{margin-left:20%}.column.is-offset-two-fifths-widescreen{margin-left:40%}.column.is-offset-three-fifths-widescreen{margin-left:60%}.column.is-offset-four-fifths-widescreen{margin-left:80%}.column.is-0-widescreen{flex:none;width:0%}.column.is-offset-0-widescreen{margin-left:0}.column.is-1-widescreen{flex:none;width:8.33333%}.column.is-offset-1-widescreen{margin-left:8.33333%}.column.is-2-widescreen{flex:none;width:16.66667%}.column.is-offset-2-widescreen{margin-left:16.66667%}.column.is-3-widescreen{flex:none;width:25%}.column.is-offset-3-widescreen{margin-left:25%}.column.is-4-widescreen{flex:none;width:33.33333%}.column.is-offset-4-widescreen{margin-left:33.33333%}.column.is-5-widescreen{flex:none;width:41.66667%}.column.is-offset-5-widescreen{margin-left:41.66667%}.column.is-6-widescreen{flex:none;width:50%}.column.is-offset-6-widescreen{margin-left:50%}.column.is-7-widescreen{flex:none;width:58.33333%}.column.is-offset-7-widescreen{margin-left:58.33333%}.column.is-8-widescreen{flex:none;width:66.66667%}.column.is-offset-8-widescreen{margin-left:66.66667%}.column.is-9-widescreen{flex:none;width:75%}.column.is-offset-9-widescreen{margin-left:75%}.column.is-10-widescreen{flex:none;width:83.33333%}.column.is-offset-10-widescreen{margin-left:83.33333%}.column.is-11-widescreen{flex:none;width:91.66667%}.column.is-offset-11-widescreen{margin-left:91.66667%}.column.is-12-widescreen{flex:none;width:100%}.column.is-offset-12-widescreen{margin-left:100%}}@media screen and (min-width:1408px){.column.is-narrow-fullhd{flex:none}.column.is-full-fullhd{flex:none;width:100%}.column.is-three-quarters-fullhd{flex:none;width:75%}.column.is-two-thirds-fullhd{flex:none;width:66.6666%}.column.is-half-fullhd{flex:none;width:50%}.column.is-one-third-fullhd{flex:none;width:33.3333%}.column.is-one-quarter-fullhd{flex:none;width:25%}.column.is-one-fifth-fullhd{flex:none;width:20%}.column.is-two-fifths-fullhd{flex:none;width:40%}.column.is-three-fifths-fullhd{flex:none;width:60%}.column.is-four-fifths-fullhd{flex:none;width:80%}.column.is-offset-three-quarters-fullhd{margin-left:75%}.column.is-offset-two-thirds-fullhd{margin-left:66.6666%}.column.is-offset-half-fullhd{margin-left:50%}.column.is-offset-one-third-fullhd{margin-left:33.3333%}.column.is-offset-one-quarter-fullhd{margin-left:25%}.column.is-offset-one-fifth-fullhd{margin-left:20%}.column.is-offset-two-fifths-fullhd{margin-left:40%}.column.is-offset-three-fifths-fullhd{margin-left:60%}.column.is-offset-four-fifths-fullhd{margin-left:80%}.column.is-0-fullhd{flex:none;width:0%}.column.is-offset-0-fullhd{margin-left:0}.column.is-1-fullhd{flex:none;width:8.33333%}.column.is-offset-1-fullhd{margin-left:8.33333%}.column.is-2-fullhd{flex:none;width:16.66667%}.column.is-offset-2-fullhd{margin-left:16.66667%}.column.is-3-fullhd{flex:none;width:25%}.column.is-offset-3-fullhd{margin-left:25%}.column.is-4-fullhd{flex:none;width:33.33333%}.column.is-offset-4-fullhd{margin-left:33.33333%}.column.is-5-fullhd{flex:none;width:41.66667%}.column.is-offset-5-fullhd{margin-left:41.66667%}.column.is-6-fullhd{flex:none;width:50%}.column.is-offset-6-fullhd{margin-left:50%}.column.is-7-fullhd{flex:none;width:58.33333%}.column.is-offset-7-fullhd{margin-left:58.33333%}.column.is-8-fullhd{flex:none;width:66.66667%}.column.is-offset-8-fullhd{margin-left:66.66667%}.column.is-9-fullhd{flex:none;width:75%}.column.is-offset-9-fullhd{margin-left:75%}.column.is-10-fullhd{flex:none;width:83.33333%}.column.is-offset-10-fullhd{margin-left:83.33333%}.column.is-11-fullhd{flex:none;width:91.66667%}.column.is-offset-11-fullhd{margin-left:91.66667%}.column.is-12-fullhd{flex:none;width:100%}.column.is-offset-12-fullhd{margin-left:100%}}.columns{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.columns:last-child{margin-bottom:-.75rem}.columns:not(:last-child){margin-bottom:calc(1.5rem - .75rem)}.columns.is-centered{justify-content:center}.columns.is-gapless{margin-left:0;margin-right:0;margin-top:0}.columns.is-gapless>.column{margin:0;padding:0!important}.columns.is-gapless:not(:last-child){margin-bottom:1.5rem}.columns.is-gapless:last-child{margin-bottom:0}.columns.is-mobile{display:flex}.columns.is-multiline{flex-wrap:wrap}.columns.is-vcentered{align-items:center}@media screen and (min-width:769px),print{.columns:not(.is-desktop){display:flex}}@media screen and (min-width:1024px){.columns.is-desktop{display:flex}}.columns.is-variable{--columnGap:0.75rem;margin-left:calc(-1 * var(--columnGap));margin-right:calc(-1 * var(--columnGap))}.columns.is-variable .column{padding-left:var(--columnGap);padding-right:var(--columnGap)}.columns.is-variable.is-0{--columnGap:0rem}@media screen and (max-width:768px){.columns.is-variable.is-0-mobile{--columnGap:0rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-0-tablet{--columnGap:0rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-0-tablet-only{--columnGap:0rem}}@media screen and (max-width:1023px){.columns.is-variable.is-0-touch{--columnGap:0rem}}@media screen and (min-width:1024px){.columns.is-variable.is-0-desktop{--columnGap:0rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-0-desktop-only{--columnGap:0rem}}@media screen and (min-width:1216px){.columns.is-variable.is-0-widescreen{--columnGap:0rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-0-widescreen-only{--columnGap:0rem}}@media screen and (min-width:1408px){.columns.is-variable.is-0-fullhd{--columnGap:0rem}}.columns.is-variable.is-1{--columnGap:0.25rem}@media screen and (max-width:768px){.columns.is-variable.is-1-mobile{--columnGap:0.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-1-tablet{--columnGap:0.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-1-tablet-only{--columnGap:0.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-1-touch{--columnGap:0.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-1-desktop{--columnGap:0.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-1-desktop-only{--columnGap:0.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-1-widescreen{--columnGap:0.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-1-widescreen-only{--columnGap:0.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-1-fullhd{--columnGap:0.25rem}}.columns.is-variable.is-2{--columnGap:0.5rem}@media screen and (max-width:768px){.columns.is-variable.is-2-mobile{--columnGap:0.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-2-tablet{--columnGap:0.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-2-tablet-only{--columnGap:0.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-2-touch{--columnGap:0.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-2-desktop{--columnGap:0.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-2-desktop-only{--columnGap:0.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-2-widescreen{--columnGap:0.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-2-widescreen-only{--columnGap:0.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-2-fullhd{--columnGap:0.5rem}}.columns.is-variable.is-3{--columnGap:0.75rem}@media screen and (max-width:768px){.columns.is-variable.is-3-mobile{--columnGap:0.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-3-tablet{--columnGap:0.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-3-tablet-only{--columnGap:0.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-3-touch{--columnGap:0.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-3-desktop{--columnGap:0.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-3-desktop-only{--columnGap:0.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-3-widescreen{--columnGap:0.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-3-widescreen-only{--columnGap:0.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-3-fullhd{--columnGap:0.75rem}}.columns.is-variable.is-4{--columnGap:1rem}@media screen and (max-width:768px){.columns.is-variable.is-4-mobile{--columnGap:1rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-4-tablet{--columnGap:1rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-4-tablet-only{--columnGap:1rem}}@media screen and (max-width:1023px){.columns.is-variable.is-4-touch{--columnGap:1rem}}@media screen and (min-width:1024px){.columns.is-variable.is-4-desktop{--columnGap:1rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-4-desktop-only{--columnGap:1rem}}@media screen and (min-width:1216px){.columns.is-variable.is-4-widescreen{--columnGap:1rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-4-widescreen-only{--columnGap:1rem}}@media screen and (min-width:1408px){.columns.is-variable.is-4-fullhd{--columnGap:1rem}}.columns.is-variable.is-5{--columnGap:1.25rem}@media screen and (max-width:768px){.columns.is-variable.is-5-mobile{--columnGap:1.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-5-tablet{--columnGap:1.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-5-tablet-only{--columnGap:1.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-5-touch{--columnGap:1.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-5-desktop{--columnGap:1.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-5-desktop-only{--columnGap:1.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-5-widescreen{--columnGap:1.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-5-widescreen-only{--columnGap:1.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-5-fullhd{--columnGap:1.25rem}}.columns.is-variable.is-6{--columnGap:1.5rem}@media screen and (max-width:768px){.columns.is-variable.is-6-mobile{--columnGap:1.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-6-tablet{--columnGap:1.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-6-tablet-only{--columnGap:1.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-6-touch{--columnGap:1.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-6-desktop{--columnGap:1.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-6-desktop-only{--columnGap:1.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-6-widescreen{--columnGap:1.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-6-widescreen-only{--columnGap:1.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-6-fullhd{--columnGap:1.5rem}}.columns.is-variable.is-7{--columnGap:1.75rem}@media screen and (max-width:768px){.columns.is-variable.is-7-mobile{--columnGap:1.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-7-tablet{--columnGap:1.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-7-tablet-only{--columnGap:1.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-7-touch{--columnGap:1.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-7-desktop{--columnGap:1.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-7-desktop-only{--columnGap:1.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-7-widescreen{--columnGap:1.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-7-widescreen-only{--columnGap:1.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-7-fullhd{--columnGap:1.75rem}}.columns.is-variable.is-8{--columnGap:2rem}@media screen and (max-width:768px){.columns.is-variable.is-8-mobile{--columnGap:2rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-8-tablet{--columnGap:2rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-8-tablet-only{--columnGap:2rem}}@media screen and (max-width:1023px){.columns.is-variable.is-8-touch{--columnGap:2rem}}@media screen and (min-width:1024px){.columns.is-variable.is-8-desktop{--columnGap:2rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-8-desktop-only{--columnGap:2rem}}@media screen and (min-width:1216px){.columns.is-variable.is-8-widescreen{--columnGap:2rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-8-widescreen-only{--columnGap:2rem}}@media screen and (min-width:1408px){.columns.is-variable.is-8-fullhd{--columnGap:2rem}}.tile{align-items:stretch;display:block;flex-basis:0;flex-grow:1;flex-shrink:1;min-height:-webkit-min-content;min-height:-moz-min-content;min-height:min-content}.tile.is-ancestor{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.tile.is-ancestor:last-child{margin-bottom:-.75rem}.tile.is-ancestor:not(:last-child){margin-bottom:.75rem}.tile.is-child{margin:0!important}.tile.is-parent{padding:.75rem}.tile.is-vertical{flex-direction:column}.tile.is-vertical>.tile.is-child:not(:last-child){margin-bottom:1.5rem!important}@media screen and (min-width:769px),print{.tile:not(.is-child){display:flex}.tile.is-1{flex:none;width:8.33333%}.tile.is-2{flex:none;width:16.66667%}.tile.is-3{flex:none;width:25%}.tile.is-4{flex:none;width:33.33333%}.tile.is-5{flex:none;width:41.66667%}.tile.is-6{flex:none;width:50%}.tile.is-7{flex:none;width:58.33333%}.tile.is-8{flex:none;width:66.66667%}.tile.is-9{flex:none;width:75%}.tile.is-10{flex:none;width:83.33333%}.tile.is-11{flex:none;width:91.66667%}.tile.is-12{flex:none;width:100%}}.hero{align-items:stretch;display:flex;flex-direction:column;justify-content:space-between}.hero .navbar{background:0 0}.hero .tabs ul{border-bottom:none}.hero.is-white{background-color:#fff;color:#0a0a0a}.hero.is-white a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-white strong{color:inherit}.hero.is-white .title{color:#0a0a0a}.hero.is-white .subtitle{color:rgba(10,10,10,.9)}.hero.is-white .subtitle a:not(.button),.hero.is-white .subtitle strong{color:#0a0a0a}@media screen and (max-width:1023px){.hero.is-white .navbar-menu{background-color:#fff}}.hero.is-white .navbar-item,.hero.is-white .navbar-link{color:rgba(10,10,10,.7)}.hero.is-white .navbar-link.is-active,.hero.is-white .navbar-link:hover,.hero.is-white a.navbar-item.is-active,.hero.is-white a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.hero.is-white .tabs a{color:#0a0a0a;opacity:.9}.hero.is-white .tabs a:hover{opacity:1}.hero.is-white .tabs li.is-active a{opacity:1}.hero.is-white .tabs.is-boxed a,.hero.is-white .tabs.is-toggle a{color:#0a0a0a}.hero.is-white .tabs.is-boxed a:hover,.hero.is-white .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-white .tabs.is-boxed li.is-active a,.hero.is-white .tabs.is-boxed li.is-active a:hover,.hero.is-white .tabs.is-toggle li.is-active a,.hero.is-white .tabs.is-toggle li.is-active a:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.hero.is-white.is-bold{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-white.is-bold .navbar-menu{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}}.hero.is-black{background-color:#0a0a0a;color:#fff}.hero.is-black a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-black strong{color:inherit}.hero.is-black .title{color:#fff}.hero.is-black .subtitle{color:rgba(255,255,255,.9)}.hero.is-black .subtitle a:not(.button),.hero.is-black .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-black .navbar-menu{background-color:#0a0a0a}}.hero.is-black .navbar-item,.hero.is-black .navbar-link{color:rgba(255,255,255,.7)}.hero.is-black .navbar-link.is-active,.hero.is-black .navbar-link:hover,.hero.is-black a.navbar-item.is-active,.hero.is-black a.navbar-item:hover{background-color:#000;color:#fff}.hero.is-black .tabs a{color:#fff;opacity:.9}.hero.is-black .tabs a:hover{opacity:1}.hero.is-black .tabs li.is-active a{opacity:1}.hero.is-black .tabs.is-boxed a,.hero.is-black .tabs.is-toggle a{color:#fff}.hero.is-black .tabs.is-boxed a:hover,.hero.is-black .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-black .tabs.is-boxed li.is-active a,.hero.is-black .tabs.is-boxed li.is-active a:hover,.hero.is-black .tabs.is-toggle li.is-active a,.hero.is-black .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.hero.is-black.is-bold{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}@media screen and (max-width:768px){.hero.is-black.is-bold .navbar-menu{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}}.hero.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.hero.is-light a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-light strong{color:inherit}.hero.is-light .title{color:rgba(0,0,0,.7)}.hero.is-light .subtitle{color:rgba(0,0,0,.9)}.hero.is-light .subtitle a:not(.button),.hero.is-light .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-light .navbar-menu{background-color:#f5f5f5}}.hero.is-light .navbar-item,.hero.is-light .navbar-link{color:rgba(0,0,0,.7)}.hero.is-light .navbar-link.is-active,.hero.is-light .navbar-link:hover,.hero.is-light a.navbar-item.is-active,.hero.is-light a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.hero.is-light .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-light .tabs a:hover{opacity:1}.hero.is-light .tabs li.is-active a{opacity:1}.hero.is-light .tabs.is-boxed a,.hero.is-light .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-light .tabs.is-boxed a:hover,.hero.is-light .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-light .tabs.is-boxed li.is-active a,.hero.is-light .tabs.is-boxed li.is-active a:hover,.hero.is-light .tabs.is-toggle li.is-active a,.hero.is-light .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#f5f5f5}.hero.is-light.is-bold{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-light.is-bold .navbar-menu{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}}.hero.is-dark{background-color:#363636;color:#fff}.hero.is-dark a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-dark strong{color:inherit}.hero.is-dark .title{color:#fff}.hero.is-dark .subtitle{color:rgba(255,255,255,.9)}.hero.is-dark .subtitle a:not(.button),.hero.is-dark .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-dark .navbar-menu{background-color:#363636}}.hero.is-dark .navbar-item,.hero.is-dark .navbar-link{color:rgba(255,255,255,.7)}.hero.is-dark .navbar-link.is-active,.hero.is-dark .navbar-link:hover,.hero.is-dark a.navbar-item.is-active,.hero.is-dark a.navbar-item:hover{background-color:#292929;color:#fff}.hero.is-dark .tabs a{color:#fff;opacity:.9}.hero.is-dark .tabs a:hover{opacity:1}.hero.is-dark .tabs li.is-active a{opacity:1}.hero.is-dark .tabs.is-boxed a,.hero.is-dark .tabs.is-toggle a{color:#fff}.hero.is-dark .tabs.is-boxed a:hover,.hero.is-dark .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-dark .tabs.is-boxed li.is-active a,.hero.is-dark .tabs.is-boxed li.is-active a:hover,.hero.is-dark .tabs.is-toggle li.is-active a,.hero.is-dark .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#363636}.hero.is-dark.is-bold{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}@media screen and (max-width:768px){.hero.is-dark.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}}.hero.is-primary{background-color:#00d1b2;color:#fff}.hero.is-primary a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-primary strong{color:inherit}.hero.is-primary .title{color:#fff}.hero.is-primary .subtitle{color:rgba(255,255,255,.9)}.hero.is-primary .subtitle a:not(.button),.hero.is-primary .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-primary .navbar-menu{background-color:#00d1b2}}.hero.is-primary .navbar-item,.hero.is-primary .navbar-link{color:rgba(255,255,255,.7)}.hero.is-primary .navbar-link.is-active,.hero.is-primary .navbar-link:hover,.hero.is-primary a.navbar-item.is-active,.hero.is-primary a.navbar-item:hover{background-color:#00b89c;color:#fff}.hero.is-primary .tabs a{color:#fff;opacity:.9}.hero.is-primary .tabs a:hover{opacity:1}.hero.is-primary .tabs li.is-active a{opacity:1}.hero.is-primary .tabs.is-boxed a,.hero.is-primary .tabs.is-toggle a{color:#fff}.hero.is-primary .tabs.is-boxed a:hover,.hero.is-primary .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-primary .tabs.is-boxed li.is-active a,.hero.is-primary .tabs.is-boxed li.is-active a:hover,.hero.is-primary .tabs.is-toggle li.is-active a,.hero.is-primary .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#00d1b2}.hero.is-primary.is-bold{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}@media screen and (max-width:768px){.hero.is-primary.is-bold .navbar-menu{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}}.hero.is-link{background-color:#3273dc;color:#fff}.hero.is-link a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-link strong{color:inherit}.hero.is-link .title{color:#fff}.hero.is-link .subtitle{color:rgba(255,255,255,.9)}.hero.is-link .subtitle a:not(.button),.hero.is-link .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-link .navbar-menu{background-color:#3273dc}}.hero.is-link .navbar-item,.hero.is-link .navbar-link{color:rgba(255,255,255,.7)}.hero.is-link .navbar-link.is-active,.hero.is-link .navbar-link:hover,.hero.is-link a.navbar-item.is-active,.hero.is-link a.navbar-item:hover{background-color:#2366d1;color:#fff}.hero.is-link .tabs a{color:#fff;opacity:.9}.hero.is-link .tabs a:hover{opacity:1}.hero.is-link .tabs li.is-active a{opacity:1}.hero.is-link .tabs.is-boxed a,.hero.is-link .tabs.is-toggle a{color:#fff}.hero.is-link .tabs.is-boxed a:hover,.hero.is-link .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-link .tabs.is-boxed li.is-active a,.hero.is-link .tabs.is-boxed li.is-active a:hover,.hero.is-link .tabs.is-toggle li.is-active a,.hero.is-link .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3273dc}.hero.is-link.is-bold{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}@media screen and (max-width:768px){.hero.is-link.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}}.hero.is-info{background-color:#3298dc;color:#fff}.hero.is-info a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-info strong{color:inherit}.hero.is-info .title{color:#fff}.hero.is-info .subtitle{color:rgba(255,255,255,.9)}.hero.is-info .subtitle a:not(.button),.hero.is-info .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-info .navbar-menu{background-color:#3298dc}}.hero.is-info .navbar-item,.hero.is-info .navbar-link{color:rgba(255,255,255,.7)}.hero.is-info .navbar-link.is-active,.hero.is-info .navbar-link:hover,.hero.is-info a.navbar-item.is-active,.hero.is-info a.navbar-item:hover{background-color:#238cd1;color:#fff}.hero.is-info .tabs a{color:#fff;opacity:.9}.hero.is-info .tabs a:hover{opacity:1}.hero.is-info .tabs li.is-active a{opacity:1}.hero.is-info .tabs.is-boxed a,.hero.is-info .tabs.is-toggle a{color:#fff}.hero.is-info .tabs.is-boxed a:hover,.hero.is-info .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-info .tabs.is-boxed li.is-active a,.hero.is-info .tabs.is-boxed li.is-active a:hover,.hero.is-info .tabs.is-toggle li.is-active a,.hero.is-info .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3298dc}.hero.is-info.is-bold{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}@media screen and (max-width:768px){.hero.is-info.is-bold .navbar-menu{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}}.hero.is-success{background-color:#48c774;color:#fff}.hero.is-success a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-success strong{color:inherit}.hero.is-success .title{color:#fff}.hero.is-success .subtitle{color:rgba(255,255,255,.9)}.hero.is-success .subtitle a:not(.button),.hero.is-success .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-success .navbar-menu{background-color:#48c774}}.hero.is-success .navbar-item,.hero.is-success .navbar-link{color:rgba(255,255,255,.7)}.hero.is-success .navbar-link.is-active,.hero.is-success .navbar-link:hover,.hero.is-success a.navbar-item.is-active,.hero.is-success a.navbar-item:hover{background-color:#3abb67;color:#fff}.hero.is-success .tabs a{color:#fff;opacity:.9}.hero.is-success .tabs a:hover{opacity:1}.hero.is-success .tabs li.is-active a{opacity:1}.hero.is-success .tabs.is-boxed a,.hero.is-success .tabs.is-toggle a{color:#fff}.hero.is-success .tabs.is-boxed a:hover,.hero.is-success .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-success .tabs.is-boxed li.is-active a,.hero.is-success .tabs.is-boxed li.is-active a:hover,.hero.is-success .tabs.is-toggle li.is-active a,.hero.is-success .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#48c774}.hero.is-success.is-bold{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}@media screen and (max-width:768px){.hero.is-success.is-bold .navbar-menu{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}}.hero.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.hero.is-warning a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-warning strong{color:inherit}.hero.is-warning .title{color:rgba(0,0,0,.7)}.hero.is-warning .subtitle{color:rgba(0,0,0,.9)}.hero.is-warning .subtitle a:not(.button),.hero.is-warning .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-warning .navbar-menu{background-color:#ffdd57}}.hero.is-warning .navbar-item,.hero.is-warning .navbar-link{color:rgba(0,0,0,.7)}.hero.is-warning .navbar-link.is-active,.hero.is-warning .navbar-link:hover,.hero.is-warning a.navbar-item.is-active,.hero.is-warning a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.hero.is-warning .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-warning .tabs a:hover{opacity:1}.hero.is-warning .tabs li.is-active a{opacity:1}.hero.is-warning .tabs.is-boxed a,.hero.is-warning .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-warning .tabs.is-boxed a:hover,.hero.is-warning .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-warning .tabs.is-boxed li.is-active a,.hero.is-warning .tabs.is-boxed li.is-active a:hover,.hero.is-warning .tabs.is-toggle li.is-active a,.hero.is-warning .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#ffdd57}.hero.is-warning.is-bold{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}@media screen and (max-width:768px){.hero.is-warning.is-bold .navbar-menu{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}}.hero.is-danger{background-color:#f14668;color:#fff}.hero.is-danger a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-danger strong{color:inherit}.hero.is-danger .title{color:#fff}.hero.is-danger .subtitle{color:rgba(255,255,255,.9)}.hero.is-danger .subtitle a:not(.button),.hero.is-danger .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-danger .navbar-menu{background-color:#f14668}}.hero.is-danger .navbar-item,.hero.is-danger .navbar-link{color:rgba(255,255,255,.7)}.hero.is-danger .navbar-link.is-active,.hero.is-danger .navbar-link:hover,.hero.is-danger a.navbar-item.is-active,.hero.is-danger a.navbar-item:hover{background-color:#ef2e55;color:#fff}.hero.is-danger .tabs a{color:#fff;opacity:.9}.hero.is-danger .tabs a:hover{opacity:1}.hero.is-danger .tabs li.is-active a{opacity:1}.hero.is-danger .tabs.is-boxed a,.hero.is-danger .tabs.is-toggle a{color:#fff}.hero.is-danger .tabs.is-boxed a:hover,.hero.is-danger .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-danger .tabs.is-boxed li.is-active a,.hero.is-danger .tabs.is-boxed li.is-active a:hover,.hero.is-danger .tabs.is-toggle li.is-active a,.hero.is-danger .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#f14668}.hero.is-danger.is-bold{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}@media screen and (max-width:768px){.hero.is-danger.is-bold .navbar-menu{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}}.hero.is-small .hero-body{padding:1.5rem}@media screen and (min-width:769px),print{.hero.is-medium .hero-body{padding:9rem 1.5rem}}@media screen and (min-width:769px),print{.hero.is-large .hero-body{padding:18rem 1.5rem}}.hero.is-fullheight .hero-body,.hero.is-fullheight-with-navbar .hero-body,.hero.is-halfheight .hero-body{align-items:center;display:flex}.hero.is-fullheight .hero-body>.container,.hero.is-fullheight-with-navbar .hero-body>.container,.hero.is-halfheight .hero-body>.container{flex-grow:1;flex-shrink:1}.hero.is-halfheight{min-height:50vh}.hero.is-fullheight{min-height:100vh}.hero-video{overflow:hidden}.hero-video video{left:50%;min-height:100%;min-width:100%;position:absolute;top:50%;transform:translate3d(-50%,-50%,0)}.hero-video.is-transparent{opacity:.3}@media screen and (max-width:768px){.hero-video{display:none}}.hero-buttons{margin-top:1.5rem}@media screen and (max-width:768px){.hero-buttons .button{display:flex}.hero-buttons .button:not(:last-child){margin-bottom:.75rem}}@media screen and (min-width:769px),print{.hero-buttons{display:flex;justify-content:center}.hero-buttons .button:not(:last-child){margin-right:1.5rem}}.hero-foot,.hero-head{flex-grow:0;flex-shrink:0}.hero-body{flex-grow:1;flex-shrink:0;padding:3rem 1.5rem}.section{padding:3rem 1.5rem}@media screen and (min-width:1024px){.section.is-medium{padding:9rem 1.5rem}.section.is-large{padding:18rem 1.5rem}}.footer{background-color:#fafafa;padding:3rem 1.5rem 6rem} \ No newline at end of file diff --git a/html/css/fa_LICENSE.txt b/html/css/fa_LICENSE.txt deleted file mode 100644 index f31bef9..0000000 --- a/html/css/fa_LICENSE.txt +++ /dev/null @@ -1,34 +0,0 @@ -Font Awesome Free License -------------------------- - -Font Awesome Free is free, open source, and GPL friendly. You can use it for -commercial projects, open source projects, or really almost whatever you want. -Full Font Awesome Free license: https://fontawesome.com/license/free. - -# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) -In the Font Awesome Free download, the CC BY 4.0 license applies to all icons -packaged as SVG and JS file types. - -# Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL) -In the Font Awesome Free download, the SIL OFL license applies to all icons -packaged as web and desktop font files. - -# Code: MIT License (https://opensource.org/licenses/MIT) -In the Font Awesome Free download, the MIT license applies to all non-font and -non-icon files. - -# Attribution -Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font -Awesome Free files already contain embedded comments with sufficient -attribution, so you shouldn't need to do anything additional when using these -files normally. - -We've kept attribution comments terse, so we ask that you do not actively work -to remove them from files, especially code. They're a great way for folks to -learn about Font Awesome. - -# Brand Icons -All brand icons are trademarks of their respective owners. The use of these -trademarks does not indicate endorsement of the trademark holder by Font -Awesome, nor vice versa. **Please do not use brand logos for any purpose except -to represent the company, product, or service to which they refer.** diff --git a/html/css/katana.css b/html/css/katana.css deleted file mode 100644 index ab604a3..0000000 --- a/html/css/katana.css +++ /dev/null @@ -1,32 +0,0 @@ -#header-image { - height: auto; - width: 380px; - display: block; - margin-left: auto; - margin-right: auto; -} - -#header-image-wrap { - width: 380px; - background-color: #AAA; - margin: auto; -} - -#header-section { - background-color: #AAA; -} - -.cat-header { - height: 1.6em; - vertical-align: middle; -} - - -#title-header { - padding-left: 1.2em; - padding-right: 1.2em; -} - -.status-bar { - padding-top: 0.3em; -} \ No newline at end of file diff --git a/html/css/readme.txt b/html/css/readme.txt deleted file mode 100644 index 687eba5..0000000 --- a/html/css/readme.txt +++ /dev/null @@ -1,3 +0,0 @@ -bulma.min.css is from Bulma version 0.8.2 (see Bulma_LICENCE.txt) -all.css is from fontawesome free 5.13.0 (see fa_LICENSE.txt) -The ../webfonts folder is also from fontawesome. \ No newline at end of file diff --git a/html/favicon.ico b/html/favicon.ico deleted file mode 100644 index 102550b..0000000 Binary files a/html/favicon.ico and /dev/null differ diff --git a/html/images/base-services.svg b/html/images/base-services.svg deleted file mode 100644 index 06f8774..0000000 --- a/html/images/base-services.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/html/images/katana-logo.svg b/html/images/katana-logo.svg deleted file mode 100644 index be6a819..0000000 --- a/html/images/katana-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/html/images/samurai-2258604_320.jpg b/html/images/samurai-2258604_320.jpg deleted file mode 100644 index c91abf6..0000000 Binary files a/html/images/samurai-2258604_320.jpg and /dev/null differ diff --git a/html/images/targets.svg b/html/images/targets.svg deleted file mode 100644 index 6908bd1..0000000 --- a/html/images/targets.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/html/images/tools.svg b/html/images/tools.svg deleted file mode 100644 index 746f796..0000000 --- a/html/images/tools.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/html/js/axios.min.js b/html/js/axios.min.js deleted file mode 100644 index b87c0e3..0000000 --- a/html/js/axios.min.js +++ /dev/null @@ -1,3 +0,0 @@ -/* axios v0.19.2 | (c) 2020 by Matt Zabriskie */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.axios=t():e.axios=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={exports:{},id:r,loaded:!1};return e[r].call(o.exports,o,o.exports,t),o.loaded=!0,o.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t,n){e.exports=n(1)},function(e,t,n){"use strict";function r(e){var t=new s(e),n=i(s.prototype.request,t);return o.extend(n,s.prototype,t),o.extend(n,t),n}var o=n(2),i=n(3),s=n(4),a=n(22),u=n(10),c=r(u);c.Axios=s,c.create=function(e){return r(a(c.defaults,e))},c.Cancel=n(23),c.CancelToken=n(24),c.isCancel=n(9),c.all=function(e){return Promise.all(e)},c.spread=n(25),e.exports=c,e.exports.default=c},function(e,t,n){"use strict";function r(e){return"[object Array]"===j.call(e)}function o(e){return"undefined"==typeof e}function i(e){return null!==e&&!o(e)&&null!==e.constructor&&!o(e.constructor)&&"function"==typeof e.constructor.isBuffer&&e.constructor.isBuffer(e)}function s(e){return"[object ArrayBuffer]"===j.call(e)}function a(e){return"undefined"!=typeof FormData&&e instanceof FormData}function u(e){var t;return t="undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&e.buffer instanceof ArrayBuffer}function c(e){return"string"==typeof e}function f(e){return"number"==typeof e}function p(e){return null!==e&&"object"==typeof e}function d(e){return"[object Date]"===j.call(e)}function l(e){return"[object File]"===j.call(e)}function h(e){return"[object Blob]"===j.call(e)}function m(e){return"[object Function]"===j.call(e)}function y(e){return p(e)&&m(e.pipe)}function g(e){return"undefined"!=typeof URLSearchParams&&e instanceof URLSearchParams}function v(e){return e.replace(/^\s*/,"").replace(/\s*$/,"")}function x(){return("undefined"==typeof navigator||"ReactNative"!==navigator.product&&"NativeScript"!==navigator.product&&"NS"!==navigator.product)&&("undefined"!=typeof window&&"undefined"!=typeof document)}function w(e,t){if(null!==e&&"undefined"!=typeof e)if("object"!=typeof e&&(e=[e]),r(e))for(var n=0,o=e.length;n=200&&e<300}};u.headers={common:{Accept:"application/json, text/plain, */*"}},i.forEach(["delete","get","head"],function(e){u.headers[e]={}}),i.forEach(["post","put","patch"],function(e){u.headers[e]=i.merge(a)}),e.exports=u},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t){r.forEach(e,function(n,r){r!==t&&r.toUpperCase()===t.toUpperCase()&&(e[t]=n,delete e[r])})}},function(e,t,n){"use strict";var r=n(2),o=n(13),i=n(5),s=n(16),a=n(19),u=n(20),c=n(14);e.exports=function(e){return new Promise(function(t,f){var p=e.data,d=e.headers;r.isFormData(p)&&delete d["Content-Type"];var l=new XMLHttpRequest;if(e.auth){var h=e.auth.username||"",m=e.auth.password||"";d.Authorization="Basic "+btoa(h+":"+m)}var y=s(e.baseURL,e.url);if(l.open(e.method.toUpperCase(),i(y,e.params,e.paramsSerializer),!0),l.timeout=e.timeout,l.onreadystatechange=function(){if(l&&4===l.readyState&&(0!==l.status||l.responseURL&&0===l.responseURL.indexOf("file:"))){var n="getAllResponseHeaders"in l?a(l.getAllResponseHeaders()):null,r=e.responseType&&"text"!==e.responseType?l.response:l.responseText,i={data:r,status:l.status,statusText:l.statusText,headers:n,config:e,request:l};o(t,f,i),l=null}},l.onabort=function(){l&&(f(c("Request aborted",e,"ECONNABORTED",l)),l=null)},l.onerror=function(){f(c("Network Error",e,null,l)),l=null},l.ontimeout=function(){var t="timeout of "+e.timeout+"ms exceeded";e.timeoutErrorMessage&&(t=e.timeoutErrorMessage),f(c(t,e,"ECONNABORTED",l)),l=null},r.isStandardBrowserEnv()){var g=n(21),v=(e.withCredentials||u(y))&&e.xsrfCookieName?g.read(e.xsrfCookieName):void 0;v&&(d[e.xsrfHeaderName]=v)}if("setRequestHeader"in l&&r.forEach(d,function(e,t){"undefined"==typeof p&&"content-type"===t.toLowerCase()?delete d[t]:l.setRequestHeader(t,e)}),r.isUndefined(e.withCredentials)||(l.withCredentials=!!e.withCredentials),e.responseType)try{l.responseType=e.responseType}catch(t){if("json"!==e.responseType)throw t}"function"==typeof e.onDownloadProgress&&l.addEventListener("progress",e.onDownloadProgress),"function"==typeof e.onUploadProgress&&l.upload&&l.upload.addEventListener("progress",e.onUploadProgress),e.cancelToken&&e.cancelToken.promise.then(function(e){l&&(l.abort(),f(e),l=null)}),void 0===p&&(p=null),l.send(p)})}},function(e,t,n){"use strict";var r=n(14);e.exports=function(e,t,n){var o=n.config.validateStatus;!o||o(n.status)?e(n):t(r("Request failed with status code "+n.status,n.config,null,n.request,n))}},function(e,t,n){"use strict";var r=n(15);e.exports=function(e,t,n,o,i){var s=new Error(e);return r(s,t,n,o,i)}},function(e,t){"use strict";e.exports=function(e,t,n,r,o){return e.config=t,n&&(e.code=n),e.request=r,e.response=o,e.isAxiosError=!0,e.toJSON=function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:this.config,code:this.code}},e}},function(e,t,n){"use strict";var r=n(17),o=n(18);e.exports=function(e,t){return e&&!r(t)?o(e,t):t}},function(e,t){"use strict";e.exports=function(e){return/^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(e)}},function(e,t){"use strict";e.exports=function(e,t){return t?e.replace(/\/+$/,"")+"/"+t.replace(/^\/+/,""):e}},function(e,t,n){"use strict";var r=n(2),o=["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"];e.exports=function(e){var t,n,i,s={};return e?(r.forEach(e.split("\n"),function(e){if(i=e.indexOf(":"),t=r.trim(e.substr(0,i)).toLowerCase(),n=r.trim(e.substr(i+1)),t){if(s[t]&&o.indexOf(t)>=0)return;"set-cookie"===t?s[t]=(s[t]?s[t]:[]).concat([n]):s[t]=s[t]?s[t]+", "+n:n}}),s):s}},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){function e(e){var t=e;return n&&(o.setAttribute("href",t),t=o.href),o.setAttribute("href",t),{href:o.href,protocol:o.protocol?o.protocol.replace(/:$/,""):"",host:o.host,search:o.search?o.search.replace(/^\?/,""):"",hash:o.hash?o.hash.replace(/^#/,""):"",hostname:o.hostname,port:o.port,pathname:"/"===o.pathname.charAt(0)?o.pathname:"/"+o.pathname}}var t,n=/(msie|trident)/i.test(navigator.userAgent),o=document.createElement("a");return t=e(window.location.href),function(n){var o=r.isString(n)?e(n):n;return o.protocol===t.protocol&&o.host===t.host}}():function(){return function(){return!0}}()},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){return{write:function(e,t,n,o,i,s){var a=[];a.push(e+"="+encodeURIComponent(t)),r.isNumber(n)&&a.push("expires="+new Date(n).toGMTString()),r.isString(o)&&a.push("path="+o),r.isString(i)&&a.push("domain="+i),s===!0&&a.push("secure"),document.cookie=a.join("; ")},read:function(e){var t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove:function(e){this.write(e,"",Date.now()-864e5)}}}():function(){return{write:function(){},read:function(){return null},remove:function(){}}}()},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t){t=t||{};var n={},o=["url","method","params","data"],i=["headers","auth","proxy"],s=["baseURL","url","transformRequest","transformResponse","paramsSerializer","timeout","withCredentials","adapter","responseType","xsrfCookieName","xsrfHeaderName","onUploadProgress","onDownloadProgress","maxContentLength","validateStatus","maxRedirects","httpAgent","httpsAgent","cancelToken","socketPath"];r.forEach(o,function(e){"undefined"!=typeof t[e]&&(n[e]=t[e])}),r.forEach(i,function(o){r.isObject(t[o])?n[o]=r.deepMerge(e[o],t[o]):"undefined"!=typeof t[o]?n[o]=t[o]:r.isObject(e[o])?n[o]=r.deepMerge(e[o]):"undefined"!=typeof e[o]&&(n[o]=e[o])}),r.forEach(s,function(r){"undefined"!=typeof t[r]?n[r]=t[r]:"undefined"!=typeof e[r]&&(n[r]=e[r])});var a=o.concat(i).concat(s),u=Object.keys(t).filter(function(e){return a.indexOf(e)===-1});return r.forEach(u,function(r){"undefined"!=typeof t[r]?n[r]=t[r]:"undefined"!=typeof e[r]&&(n[r]=e[r])}),n}},function(e,t){"use strict";function n(e){this.message=e}n.prototype.toString=function(){return"Cancel"+(this.message?": "+this.message:"")},n.prototype.__CANCEL__=!0,e.exports=n},function(e,t,n){"use strict";function r(e){if("function"!=typeof e)throw new TypeError("executor must be a function.");var t;this.promise=new Promise(function(e){t=e});var n=this;e(function(e){n.reason||(n.reason=new o(e),t(n.reason))})}var o=n(23);r.prototype.throwIfRequested=function(){if(this.reason)throw this.reason},r.source=function(){var e,t=new r(function(t){e=t});return{token:t,cancel:e}},e.exports=r},function(e,t){"use strict";e.exports=function(e){return function(t){return e.apply(null,t)}}}])}); -//# sourceMappingURL=axios.min.map \ No newline at end of file diff --git a/html/js/katana.js b/html/js/katana.js deleted file mode 100644 index 1d7f797..0000000 --- a/html/js/katana.js +++ /dev/null @@ -1,134 +0,0 @@ -function runAndWaitForAction(action, module, href, message) { - axios.get('/'+action+'/'+module).then(function (response) { - console.log("Success!"); - console.log(response); - renderActionsForStatus(response.data.name, response.data.status, href, message); - pollForStatus(() => { - return axios.get('/status/' + module); - }).then(data => { - renderActionsForStatus(data.name, data.status, href, "", data.actions); - renderName(data.name, data.status, href); - document.getElementById("notifications").classList.add('is-hidden'); - }).catch(() => console.log('Polling failed.')); - }) - .catch(function (error) { - console.log(error); - }); -} - - -function startModule(event, name, href='') { - setNotification("Starting module: " + name); - runAndWaitForAction('start', name, href, 'Starting...'); -} - -function stopModule(event, name, href='') { - setNotification("Stopping module: " + name); - runAndWaitForAction('stop', name, href, 'Stopping...'); -} - -function installModule(event, name, href='') { - setNotification("Installing module: " + name); - runAndWaitForAction('install', name, href, 'Installing...'); -} - -function removeModule(event, name, href='') { - setNotification("Removing module: " + name); - runAndWaitForAction('remove', name, href, 'Removing...'); -} - -function setNotification(message) { - console.log("Notification: " + message); - let spinner = '' - let fullMessage = spinner + " " + spinner + " " + spinner + message + spinner + " " + spinner + " " + spinner; - document.getElementById("notifications").innerHTML = fullMessage; - document.getElementById("notifications").classList.remove('is-hidden'); -} - -// The polling status check -function pollForStatus(fn, timeout, interval) { - var endTime = Number(new Date()) + (timeout || 120000); - interval = interval || 3000; - - var checkCondition = function(resolve, reject) { - var ajax = fn(); - // dive into the ajax promise - ajax.then( function(response){ - // If the condition is met, we're done! - if(response.data.status !== 'changing') { - resolve(response.data); - } - // If the condition isn't met but the timeout hasn't elapsed, go again - else if (Number(new Date()) < endTime) { - setTimeout(checkCondition, interval, resolve, reject); - } - // Didn't match and too much time, reject! - else { - reject(new Error('timed out for ' + fn + ': ' + arguments)); - } - }); - }; - - return new Promise(checkCondition); -} - - -function renderActionsForStatus(module, status, href='', changeText="Busy...", actions) { - let action_icons = []; - let params = 'this, \''+module+'\''; - if (href !== '') { - params = 'this, \''+module+'\', \''+href+'\''; - } - - if (status === 'not installed' && actions.includes('install')) { - action_icons.push(''); - } - if (status === 'stopped' && actions.includes('start')) { - action_icons.push(''); - } - if (status === 'running' && actions.includes('stop')) { - action_icons.push(''); - } - if ((status === 'installed' || status === 'stopped') && actions.includes('remove')) { - action_icons.push(''); - } - if (status === 'changing') { - action_icons.push(''); - } - - document.getElementById(module+"-actions").innerHTML = action_icons.join(''); - -} - -function renderName(module, status, href='') { - let name_parts = []; - - if (href == '') { - name_parts.push(module); - } else { - if (status === 'running') { - name_parts.push('Open '+module+'
'); - } else { - name_parts.push('
') - } - - name_parts.push('
'); - - if (status === 'not installed') { - name_parts.push('Status'+status+' '); - } else if (status === 'stopped') { - name_parts.push('Status'+status+'
'); - } else if (status == 'running') { - name_parts.push('Status'+status+''); - } else { - name_parts.push('Status'+status+''); - } - - name_parts.push(''); - } - - document.getElementById(module+"-name").innerHTML = name_parts.join(''); - -} - -console.log("katana.js loaded."); \ No newline at end of file diff --git a/html/webfonts/fa-brands-400.eot b/html/webfonts/fa-brands-400.eot deleted file mode 100644 index 5c20e50..0000000 Binary files a/html/webfonts/fa-brands-400.eot and /dev/null differ diff --git a/html/webfonts/fa-brands-400.svg b/html/webfonts/fa-brands-400.svg deleted file mode 100644 index 46ad237..0000000 --- a/html/webfonts/fa-brands-400.svg +++ /dev/null @@ -1,3570 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/html/webfonts/fa-brands-400.ttf b/html/webfonts/fa-brands-400.ttf deleted file mode 100644 index 9416a68..0000000 Binary files a/html/webfonts/fa-brands-400.ttf and /dev/null differ diff --git a/html/webfonts/fa-brands-400.woff b/html/webfonts/fa-brands-400.woff deleted file mode 100644 index 6c1b21e..0000000 Binary files a/html/webfonts/fa-brands-400.woff and /dev/null differ diff --git a/html/webfonts/fa-brands-400.woff2 b/html/webfonts/fa-brands-400.woff2 deleted file mode 100644 index 48b3ca3..0000000 Binary files a/html/webfonts/fa-brands-400.woff2 and /dev/null differ diff --git a/html/webfonts/fa-regular-400.eot b/html/webfonts/fa-regular-400.eot deleted file mode 100644 index 8b54d34..0000000 Binary files a/html/webfonts/fa-regular-400.eot and /dev/null differ diff --git a/html/webfonts/fa-regular-400.svg b/html/webfonts/fa-regular-400.svg deleted file mode 100644 index 48634a9..0000000 --- a/html/webfonts/fa-regular-400.svg +++ /dev/null @@ -1,803 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/html/webfonts/fa-regular-400.ttf b/html/webfonts/fa-regular-400.ttf deleted file mode 100644 index 05d482e..0000000 Binary files a/html/webfonts/fa-regular-400.ttf and /dev/null differ diff --git a/html/webfonts/fa-regular-400.woff b/html/webfonts/fa-regular-400.woff deleted file mode 100644 index 24de566..0000000 Binary files a/html/webfonts/fa-regular-400.woff and /dev/null differ diff --git a/html/webfonts/fa-regular-400.woff2 b/html/webfonts/fa-regular-400.woff2 deleted file mode 100644 index 7e0118e..0000000 Binary files a/html/webfonts/fa-regular-400.woff2 and /dev/null differ diff --git a/html/webfonts/fa-solid-900.eot b/html/webfonts/fa-solid-900.eot deleted file mode 100644 index 2bee102..0000000 Binary files a/html/webfonts/fa-solid-900.eot and /dev/null differ diff --git a/html/webfonts/fa-solid-900.svg b/html/webfonts/fa-solid-900.svg deleted file mode 100644 index 7742838..0000000 --- a/html/webfonts/fa-solid-900.svg +++ /dev/null @@ -1,4938 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/html/webfonts/fa-solid-900.ttf b/html/webfonts/fa-solid-900.ttf deleted file mode 100644 index 020767f..0000000 Binary files a/html/webfonts/fa-solid-900.ttf and /dev/null differ diff --git a/html/webfonts/fa-solid-900.woff b/html/webfonts/fa-solid-900.woff deleted file mode 100644 index 0716002..0000000 Binary files a/html/webfonts/fa-solid-900.woff and /dev/null differ diff --git a/html/webfonts/fa-solid-900.woff2 b/html/webfonts/fa-solid-900.woff2 deleted file mode 100644 index 978a681..0000000 Binary files a/html/webfonts/fa-solid-900.woff2 and /dev/null differ diff --git a/icons/burpsuite.png b/icons/burpsuite.png deleted file mode 100644 index 33c2017..0000000 Binary files a/icons/burpsuite.png and /dev/null differ diff --git a/icons/postman-logo.png b/icons/postman-logo.png deleted file mode 100644 index 7829d4f..0000000 Binary files a/icons/postman-logo.png and /dev/null differ diff --git a/icons/samurai-icon.png b/icons/samurai-icon.png deleted file mode 100644 index e920e4c..0000000 Binary files a/icons/samurai-icon.png and /dev/null differ diff --git a/icons/zap.png b/icons/zap.png deleted file mode 100644 index d1f03b0..0000000 Binary files a/icons/zap.png and /dev/null differ diff --git a/katanacli.py b/katanacli.py deleted file mode 100644 index adf05dd..0000000 --- a/katanacli.py +++ /dev/null @@ -1,84 +0,0 @@ -#! /usr/bin/python3 -import katanacore -import argparse -import katanaerrors -import os - - -def list_modules(args): - results = katanacore.list_modules() - for mod in results: - print("{} - {}".format(mod.get_name(), mod.get_description())) # TODO: Pretty-print this - - -def install_module(args): - katanacore.install_module(args.name, ((args.step is not None) and args.step)) - - -def remove_module(args): - katanacore.remove_module(args.name, ((args.step is not None) and args.step)) - - -def start_module(args): - katanacore.start_module(args.name) - - -def stop_module(args): - katanacore.stop_module(args.name) - - -def status_module(args): - print("Status: {}".format(katanacore.status_module(args.name))) - -def lock_modules(args): - katanacore.lock_modules() - print("Modules are locked. Check katana.lock file to verify list.") - - -if __name__ == "__main__": - if not os.geteuid() == 0: - print("\nWARNING: This script must be run as root. Please type 'sudo' before the command.\n") - exit(1) - - parser = argparse.ArgumentParser(description='Utility for managing SamuraiWTF modules.') - subparsers = parser.add_subparsers() - - list = subparsers.add_parser('list') - # list.add_argument('bar') - list.set_defaults(func=list_modules) - - install = subparsers.add_parser('install') - install.add_argument('name') - install.add_argument('--step', action='store_true') - install.set_defaults(func=install_module) - - remove = subparsers.add_parser('remove') - remove.add_argument('name') - remove.add_argument('--step', action='store_true') - remove.set_defaults(func=remove_module) - - start = subparsers.add_parser('start') - start.add_argument('name') - start.set_defaults(func=start_module) - - stop = subparsers.add_parser('stop') - stop.add_argument('name') - stop.set_defaults(func=stop_module) - - status = subparsers.add_parser('status') - status.add_argument('name') - status.set_defaults(func=status_module) - - lock = subparsers.add_parser('lock') - lock.set_defaults(func=lock_modules) - - args = parser.parse_args() - - try: - if 'func' in args: - args.func(args) - else: - parser.print_usage() - except katanaerrors.WTFError as err: - print("ERROR {}".format(err.message)) - parser.print_usage() diff --git a/katanacore.py b/katanacore.py deleted file mode 100644 index 152383e..0000000 --- a/katanacore.py +++ /dev/null @@ -1,140 +0,0 @@ -from os import listdir, path -from os.path import isdir, join, dirname, realpath, abspath -import yaml -import katanaerrors -import re - -module_dict = {} -locked_modules = [] -lock_file_read = False - - -def load_module_info(path): - with open(path, 'r') as stream: - module_info = yaml.load(stream, Loader=yaml.SafeLoader) - module_info['path'] = dirname(path) - - if re.fullmatch('[a-zA-Z][a-zA-Z0-9\-_]+', module_info['name']): - provisioner_class = module_info.get("class", "provisioners.DefaultProvisioner") - if "." in provisioner_class: - class_name = provisioner_class[provisioner_class.rindex(".") + 1:] - else: - class_name = provisioner_class - - mod = __import__(provisioner_class, fromlist=[class_name]) - klass = getattr(mod, class_name) - - provisioner = klass(module_info) - module_dict[module_info.get('name').lower()] = provisioner - - return provisioner - else: - print(f"ERROR: Module name is invalid. It must be a valid css id: {module_info['name']}") - - -def list_modules(path=None, module_list=None): - if module_list is None: - module_list = [] - if path is None: - my_path = abspath(dirname(__file__)) - path = realpath(join(my_path, "modules")) - - if len(module_list) == 0: - module_dict.clear() - - file_list = listdir(path) - for f in file_list: - file_path = join(path, f) - if isdir(file_path): - list_modules(file_path, module_list) - elif f.endswith(".yml"): - module_info = load_module_info(file_path) - if module_info is not None: - module_list.append(module_info) - return module_list - - -def get_module_info(name): - return module_dict.get(name) - - -def _run_function(module_name, function_name, step=False): - if len(module_dict) == 0: - list_modules() - - provisioner = module_dict.get(module_name.lower()) - if provisioner is None: - raise katanaerrors.ModuleNotFound(module_name) - - if hasattr(provisioner, function_name) and callable(getattr(provisioner, function_name)): - function_to_call = getattr(provisioner, function_name) - return function_to_call(step) - else: - raise katanaerrors.NotImplemented(function_name, type(provisioner).__name__) - - -def install_module(name, step=False): - _run_function(name, "install", step) - - -def remove_module(name, step=False): - _run_function(name, "remove", step) - - -def start_module(name): - _run_function(name, "start") - - -def stop_module(name): - _run_function(name, "stop") - - -def status_module(name): - return _run_function(name, "status") - - -def get_available_actions(module_name): - provisioner = module_dict.get(module_name.lower()) - if provisioner is None: - raise katanaerrors.ModuleNotFound(module_name) - - return provisioner.has_actions(module_name in load_locked_modules()) - - -def lock_modules(): - global lock_file_read - locked_modules.clear() - - if len(module_dict) == 0: - list_modules() - - for module_name in module_dict.keys(): - status = status_module(module_name) - if status in ['running', 'installed', 'stopped']: - locked_modules.append(module_name) - - my_path = abspath(dirname(__file__)) - lock_file = join(my_path, "katana.lock") - - with open(lock_file, 'w') as lf: - lf.write("\n".join(locked_modules)) - lock_file_read = False - - -def load_locked_modules(): - global lock_file_read - if lock_file_read: - return locked_modules - else: - my_path = abspath(dirname(__file__)) - lock_file = join(my_path, "katana.lock") - - if path.exists(lock_file): - locked_modules.clear() - with open(lock_file, 'r') as lf: - for module in lf.readlines(): - locked_modules.append(module.strip()) - else: - locked_modules.clear() - lock_file_read = True - return locked_modules diff --git a/katanaerrors.py b/katanaerrors.py deleted file mode 100644 index 80d97ef..0000000 --- a/katanaerrors.py +++ /dev/null @@ -1,50 +0,0 @@ -class WTFError(Exception): - pass - - -class ModuleNotFound(WTFError): - - def __init__(self, module): - self.message = "Module not found: {}".format(module) - - -class NotImplemented(WTFError): - - def __init__(self, name, provisioner): - self.message = "Function '{}' is not implemented for the provisioner {}.".format(name, provisioner) - - def __init__(self, name, provisioner, module): - self.message = "Function '{}' is not implemented in the module '{}'.".format(name, module) - - -class MissingFunction(WTFError): - - def __init__(self, func, task_type): - self.message = "Function '{}' is missing from the '{}' plugin.".format(func, task_type) - - -class MissingRequiredParam(WTFError): - - def __init__(self, param, plugin_name): - self.message = "Plugin '{}' requires parameter '{}', but this parameter appears to be missing.".format( - plugin_name, param) - - -class UnrecognizedParamValue(WTFError): - - def __init__(self, param, param_value, plugin_name, valid_values): - self.message = "Plugin '{}' was specified with {}={}, but this must be one of: {}".format(plugin_name, param, - param_value, - valid_values) - - -class CriticalFunctionFailure(WTFError): - - def __init__(self, plugin_name, message="Unknown error"): - self.message = "Plugin '{}' suffered a critical failure: {}".format(plugin_name, message) - - -class BlockedByDependencyException(WTFError): - - def __init__(self, dependency): - self.message = f"The status of the '{dependency}' dependency could not be determined." diff --git a/katanarepo.py b/katanarepo.py deleted file mode 100644 index 5df582d..0000000 --- a/katanarepo.py +++ /dev/null @@ -1,27 +0,0 @@ -import yaml -import os - - -def read_repo(): - if os.path.exists("installed.yml"): - with open("installed.yml", 'r') as stream: - return yaml.load(stream) - else: - return {} - - -def write_repo(data): - with open("installed.yml", 'w') as stream: - yaml.dump(data, stream) - - -def set_installed(name, version): - data = read_repo() - data[name] = version - write_repo(data) - - -def set_removed(name): - data = read_repo() - del data[name] - write_repo(data) diff --git a/katanaserve.py b/katanaserve.py deleted file mode 100644 index e537d92..0000000 --- a/katanaserve.py +++ /dev/null @@ -1,200 +0,0 @@ -import cherrypy -import os -import sys -import katanacore -import threading - - -class KatanaServer(object): - - def __init__(self) -> None: - super().__init__() - self.threads = {} - - @cherrypy.expose - def index(self): - modules = self.list_modules() - columns = [ - self.render_category("Targets", "/images/targets.svg", self.build_module_list(modules.get("targets", []))), - self.render_category("Tools", "/images/tools.svg", self.build_module_list(modules.get("tools", []))), - self.render_category("Base Services", "/images/base-services.svg", self.build_module_list(modules.get("base", []))) - ] - all_columns = ''.join(columns) - all_links = self.list_external_links() - return f'{all_links}' \ - f'
' \ - f'
' \ - f'{all_columns}' \ - f'
' \ - f'
' \ - f'' - - @cherrypy.expose - @cherrypy.tools.json_out() - def status(self, module): - if self.module_is_busy(module): - return {'name': module, 'status': 'changing'} - else: - return {'name': module, 'status': katanacore.status_module(module), 'actions': katanacore.get_available_actions(module)} - - @cherrypy.expose - @cherrypy.tools.json_out() - def start(self, module): - if not self.module_is_busy(module): - t = threading.Thread(target=katanacore.start_module, args=(module,)) - self.threads[module] = t - t.start() - return {'name': module, 'status': 'changing'} - - @cherrypy.expose - @cherrypy.tools.json_out() - def stop(self, module): - if not self.module_is_busy(module): - t = threading.Thread(target=katanacore.stop_module, args=(module,)) - self.threads[module] = t - t.start() - return {'name': module, 'status': 'changing'} - - @cherrypy.expose - @cherrypy.tools.json_out() - def install(self, module): - if not self.module_is_busy(module): - t = threading.Thread(target=katanacore.install_module, args=(module,)) - self.threads[module] = t - t.start() - return {'name': module, 'status': 'changing'} - - @cherrypy.expose - @cherrypy.tools.json_out() - def remove(self, module): - if not self.module_is_busy(module): - t = threading.Thread(target=katanacore.remove_module, args=(module,)) - self.threads[module] = t - t.start() - return {'name': module, 'status': 'changing'} - - @cherrypy.expose - @cherrypy.tools.json_out() - def list(self): - results = self.list_modules() - return {'results': results} - - def module_is_busy(self, module): - if module in self.threads: - t = self.threads.get(module) - if t.is_alive(): - return True - else: - self.threads.pop(module, None) - return False - else: - return False - - def render_category(self, title, image_url, module_list): - return f'
' \ - f'

{title}

' \ - f'{module_list}
' - - def build_module_list(self, target_list): - rows = [] - for module in target_list: - name = module.get('name') - actions = self.render_actions_for_status(module.get('status', 'unknown'), name, module.get('href'), module.get('actions')) - rendered_name = self.render_module_name(module.get('status', 'unknown'), name, module.get('href')) - rows.append( - f'{rendered_name}{module["description"]}{actions}') - all_rows = ''.join(rows) - return f'{all_rows}
NameDescriptionActions
' - - def list_modules(self): - module_list = katanacore.list_modules() - results = {} - locked_modules = katanacore.load_locked_modules() - for module in module_list: - if len(locked_modules) == 0 or module.get_name() in locked_modules: - if module.get_category() not in results: - results[module.get_category()] = [] - status = katanacore.status_module(module.get_name()) - - results[module.get_category()].append( - {'name': module.get_name(), 'description': module.get_description(), 'status': status, - 'href': module.get_href(), 'actions': katanacore.get_available_actions(module.get_name())}) - for category in results: - sorted_list = sorted(results[category], key=lambda i: i['name']) - results[category] = sorted_list - return results - - def list_external_links(self): - links = [ - '', - '', - '', - '', - '' - ] - return ''.join(links) - - def render_actions_for_status(self, status, module_name, href='', actions=None): - if actions is None: - actions = [] - action_icons = [] - if href is None or len(href) == 0: - params = f'this, \'{module_name}\'' - else: - params = f'this, \'{module_name}\',\'{href}\'' - - if status == 'not installed' and 'install' in actions: - action_icons.append( - f'') - if status == 'stopped' and 'start' in actions: - action_icons.append( - f'') - if status == 'running' and 'stop' in actions: - action_icons.append( - f'') - if 'remove' in actions and (status == 'installed' or status == 'stopped'): - action_icons.append( - f'') - all_actions = ''.join(action_icons) - return f'

{all_actions}

' - - def render_status(self, status, module): - if status == 'not installed': - return f'
Status{status}
' - elif status == 'running': - return f'
Status{status}
' - elif status == 'stopped': - return f'
Status{status}
' - else: - return f'
Status{status}
' - - def render_module_name(self, status, module, href=None): - name = "unknown" - - if href is None or len(href) == 0: - name = module - elif status == 'not installed': - name = f'' - elif status == 'running': - name = f'Open {module}' - elif status == 'stopped': - name = f'' - else: - name = f'' - - return f'{name}
{self.render_status(status, module)}' - - -if __name__ == '__main__': - PATH: str = os.path.abspath(os.path.join(os.path.dirname(__file__), 'html')) - cherrypy.config.update({'server.socket_port': 8087, - 'server.socket_host': '127.0.0.1', - 'engine.autoreload.on': len(sys.argv) > 1 and sys.argv[1] == 'dev'}) - conf = { - '/': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': PATH - } - } - - cherrypy.quickstart(KatanaServer(), '/', conf) diff --git a/modules/README.md b/modules/README.md deleted file mode 100644 index df371e3..0000000 --- a/modules/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Overview of Samurai Modules - -Samurai Modules are the tools and targets that can be installed and used on the Samurai Web Testing Framework. A *Tool Module* is a binary or library that can be installed, uninstalled, and possibly executed. A *Target Module* is different in that it behaves like a service. It can be started, stopped, restarted, and has a run status. - -## Assigned Ports -To avoid conflict with other tools and targets, any new modules should run on local ports that have not been assigned to other modules. - -| *Port* | *Module* | *Purpose* | -|---------:|--------------|-----------------------------------| -| 0 - 1023 | none | Ports reserved by the host system | -| 3000 | juice-shop | application | -| 3020 | musashi | api.cors | -| 3021 | musashi | cors-dojo | -| 3041 | musashi | csp-dojo | -| 3050 | musashi | jwt-demo | -| 7000 | wayfarer | application | -| 7001 | wayfarer | api | -| 8087 | katana | katana UI | -| 8443 | none | SamuraiWTF TLS Port | -| 30080 | samurai-dojo | dojo-basic | -| 30081 | mutillidae | application | -| 31000 | dvwa | application | -| 31080 | samurai-dojo | dojo-scavenger | - -*Ports reserved by the host system \ No newline at end of file diff --git a/modules/management/docker.yml b/modules/management/docker.yml deleted file mode 100644 index e1baee2..0000000 --- a/modules/management/docker.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- - -name: docker -category: base -description: Containerization platform used for many of the SamuraiWTF targets - -start: - - service: - name: docker - state: running - -stop: - - service: - name: docker - state: stopped - -status: - running: - started: - service: docker - installed: - exists: - path: /var/lib/docker \ No newline at end of file diff --git a/modules/management/katana.yml b/modules/management/katana.yml deleted file mode 100644 index fb7ebbc..0000000 --- a/modules/management/katana.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- - -name: katana -category: management -description: A web UI for this application. -href: http://katana.test - -install: - - - name: Create service descriptor for samurai-katana - copy: - dest: /etc/systemd/system/samurai-katana.service - content: | - [Unit] - Description=Katana service - - [Service] - Type=simple - WorkingDirectory=/opt/katana/ - ExecStart=/usr/bin/python3 ./katanaserve.py - - [Install] - WantedBy=multi-user.target - mode: 0744 - -# - name: Create service socket for samurai-katana -# copy: -# dest: /etc/systemd/system/samurai-katana.socket -# content: | -# [Socket] -# ListenStream=127.0.0.1:8087 -# NoDelay=true -# -# [Install] -# WantedBy=sockets.target -# mode: 0744 - - - name: Setup hosts file entries (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 katana.wtf' - - - name: Setup hosts file entries (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 katana.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'katana.test' - proxy_pass: 'http://localhost:8087' - -# - name: Setup nginx reverse-proxy config -# copy: -# dest: /etc/nginx/conf.d/katana.conf -# content: | -# server { -# listen 80; -# server_name katana.wtf katana.test; -# location / { -# proxy_pass http://localhost:8087; -# } -# } -# mode: 0644 - - - service: - name: nginx - state: restarted - - command: - cmd: systemctl daemon-reload - -remove: - - service: - name: samurai-katana - state: stopped - - rm: - path: /usr/local/bin/start_katana.sh - - rm: - path: /etc/systemd/system/samurai-katana.service - - rm: - path: /etc/systemd/system/samurai-katana.socket - - name: Remove nginx reverse proxy and certificate config - reverseproxy: - hostname: 'katana.test' - - name: Remove hosts file entries (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 katana.wtf' - state: absent - - name: Remove hosts file entries (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 katana.test' - state: absent - - service: - name: nginx - state: restarted - - command: - cmd: systemctl daemon-reload - -start: - - service: - name: samurai-katana - state: running - -stop: - - service: - name: samurai-katana - state: stopped - -status: - running: - started: - service: samurai-katana - installed: - exists: - path: /opt/samurai/katana \ No newline at end of file diff --git a/modules/targets/amoksecurity.yml b/modules/targets/amoksecurity.yml deleted file mode 100644 index c1c5107..0000000 --- a/modules/targets/amoksecurity.yml +++ /dev/null @@ -1,60 +0,0 @@ ---- - -name: amoksecurity -category: targets -description: Convenience domain for payload hosting. -href: http://amoksecurity.test - -install: - - name: Setup hosts file entries (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 amoksecurity.test' - - - name: Create webroot for amoksecurity.test - file: - path: /var/www/amoksecurity - state: directory - - - name: Setup nginx reverse-proxy config for amoksecurity - copy: - dest: /etc/nginx/conf.d/amoksecurity.conf - content: | - server { - listen 80; - server_name amoksecurity.wtf amoksecurity.test; - location / { - root /var/www/amoksecurity; - } - } - mode: 0744 - - - service: - name: nginx - state: restarted - -remove: - - rm: - path: /var/www/amoksecurity - - rm: - path: /etc/nginx/conf.d/amoksecurity.conf - - name: Remove hosts file entry (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 amoksecurity.wtf' - state: absent - - name: Remove hosts file entry (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 amoksecurity.test' - state: absent - -status: - running: - started: - service: nginx - exists: - path: /var/www/amoksecurity - installed: - exists: - path: /var/www/amoksecurity diff --git a/modules/targets/dojo-basic-lite.yml b/modules/targets/dojo-basic-lite.yml deleted file mode 100644 index dae8644..0000000 --- a/modules/targets/dojo-basic-lite.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- - -name: dojo-basic-lite -category: targets -description: A lightweight version of the Basic Dojo training application from SamuraiWTF. -href: https://dojo-basic.test:8443 - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install Dojo Basic Lite docker container - docker: - name: dojo-basic-lite - image: ghcr.io/samuraiwtf/dojo-basic-lite:latest - ports: - 80/tcp: 31010 - - - name: Setup hosts file entries - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dojo-basic.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'dojo-basic.test' - proxy_pass: 'http://localhost:31010' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: dojo-basic-lite - - - name: Remove hosts file entry - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dojo-basic.test' - state: absent - - - name: Remove nginx reverse-proxy config - reverseproxy: - hostname: 'dojo-basic.test' - -start: - - service: - name: docker - state: running - - docker: - name: dojo-basic-lite - -stop: - - service: - name: docker - state: running - - docker: - name: dojo-basic-lite - -status: - running: - started: - docker: dojo-basic-lite - installed: - exists: - docker: dojo-basic-lite diff --git a/modules/targets/dojo-basic-lite/compose.yml b/modules/targets/dojo-basic-lite/compose.yml new file mode 100644 index 0000000..e637441 --- /dev/null +++ b/modules/targets/dojo-basic-lite/compose.yml @@ -0,0 +1,9 @@ +services: + dojo-basic-lite: + image: ghcr.io/samuraiwtf/dojo-basic-lite:latest + networks: + - katana-net + +networks: + katana-net: + external: true diff --git a/modules/targets/dojo-basic-lite/module.yml b/modules/targets/dojo-basic-lite/module.yml new file mode 100644 index 0000000..33b5eb3 --- /dev/null +++ b/modules/targets/dojo-basic-lite/module.yml @@ -0,0 +1,10 @@ +name: dojo-basic-lite +category: targets +description: Lightweight Dojo Basic training app - SQLi, XSS, and more + +compose: ./compose.yml + +proxy: + - hostname: dojo-basic + service: dojo-basic-lite + port: 80 diff --git a/modules/targets/dojo-scavenger-lite.yml b/modules/targets/dojo-scavenger-lite.yml deleted file mode 100644 index a1adaa5..0000000 --- a/modules/targets/dojo-scavenger-lite.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- - -name: dojo-scavenger-lite -category: targets -description: A lightweight version of the Scavenger Hunt training application from SamuraiWTF. -href: https://dojo-scavenger.test:8443 - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install Dojo Scavenger Lite docker container - docker: - name: dojo-scavenger-lite - image: ghcr.io/samuraiwtf/dojo-scavenger-lite:latest - ports: - 80/tcp: 31020 - - - name: Setup hosts file entries - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dojo-scavenger.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'dojo-scavenger.test' - proxy_pass: 'http://localhost:31020' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: dojo-scavenger-lite - - - name: Remove hosts file entry - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dojo-scavenger.test' - state: absent - - - name: Remove nginx reverse-proxy config - reverseproxy: - hostname: 'dojo-scavenger.test' - -start: - - service: - name: docker - state: running - - docker: - name: dojo-scavenger-lite - -stop: - - service: - name: docker - state: running - - docker: - name: dojo-scavenger-lite - -status: - running: - started: - docker: dojo-scavenger-lite - installed: - exists: - docker: dojo-scavenger-lite diff --git a/modules/targets/dojo-scavenger-lite/compose.yml b/modules/targets/dojo-scavenger-lite/compose.yml new file mode 100644 index 0000000..0fd42b2 --- /dev/null +++ b/modules/targets/dojo-scavenger-lite/compose.yml @@ -0,0 +1,9 @@ +services: + dojo-scavenger-lite: + image: ghcr.io/samuraiwtf/dojo-scavenger-lite:latest + networks: + - katana-net + +networks: + katana-net: + external: true diff --git a/modules/targets/dojo-scavenger-lite/module.yml b/modules/targets/dojo-scavenger-lite/module.yml new file mode 100644 index 0000000..74af33d --- /dev/null +++ b/modules/targets/dojo-scavenger-lite/module.yml @@ -0,0 +1,10 @@ +name: dojo-scavenger-lite +category: targets +description: Lightweight Dojo Scavenger Hunt - web security challenges + +compose: ./compose.yml + +proxy: + - hostname: dojo-scavenger + service: dojo-scavenger-lite + port: 80 diff --git a/modules/targets/dvga.yml b/modules/targets/dvga.yml deleted file mode 100644 index 82040a2..0000000 --- a/modules/targets/dvga.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- - -name: dvga -category: targets -description: Damn Vulnerable GraphQL Application. -href: http://dvga.test - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install DVGA docker container - docker: - name: dvga - image: dolevf/dvga - ports: - 5013/tcp: 5013 - - - name: Setup hosts file entries - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dvga.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'dvga.test' - proxy_pass: 'http://localhost:5013' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: dvga - - - name: Remove hosts file entry (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dvga.test' - state: absent - - - name: Remove nginx reverse-proxy config - reverseproxy: - hostname: 'dvga.test' - -start: - - service: - name: docker - state: running - - docker: - name: dvga - -stop: - - service: - name: docker - state: running - - docker: - name: dvga - -status: - running: - started: - docker: dvga - installed: - exists: - docker: dvga \ No newline at end of file diff --git a/modules/targets/dvga/compose.yml b/modules/targets/dvga/compose.yml new file mode 100644 index 0000000..8a516d7 --- /dev/null +++ b/modules/targets/dvga/compose.yml @@ -0,0 +1,11 @@ +services: + dvga: + image: dolevf/dvga + environment: + - WEB_HOST=0.0.0.0 + networks: + - katana-net + +networks: + katana-net: + external: true diff --git a/modules/targets/dvga/module.yml b/modules/targets/dvga/module.yml new file mode 100644 index 0000000..262423c --- /dev/null +++ b/modules/targets/dvga/module.yml @@ -0,0 +1,10 @@ +name: dvga +category: targets +description: Damn Vulnerable GraphQL Application - GraphQL security testing + +compose: ./compose.yml + +proxy: + - hostname: dvga + service: dvga + port: 5013 diff --git a/modules/targets/dvwa.yml b/modules/targets/dvwa.yml deleted file mode 100644 index 58590eb..0000000 --- a/modules/targets/dvwa.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- - -name: dvwa -category: targets -description: A classic test lab focused on OWASP top 10 vulnerabilities. -href: https://dvwa.test:8443 - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install DVWA docker container - docker: - name: dvwa - image: vulnerables/web-dvwa - ports: - 80/tcp: 31000 - - - name: Setup hosts file entries - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dvwa.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'dvwa.test' - proxy_pass: 'http://localhost:31000' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: dvwa - - - name: Remove hosts file entry (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 dvwa.test' - state: absent - - - name: Remove nginx reverse-proxy config - reverseproxy: - hostname: 'dvwa.test' - -start: - - service: - name: docker - state: running - - docker: - name: dvwa - -stop: - - service: - name: docker - state: running - - docker: - name: dvwa - -status: - running: - started: - docker: dvwa - installed: - exists: - docker: dvwa \ No newline at end of file diff --git a/modules/targets/dvwa/compose.yml b/modules/targets/dvwa/compose.yml new file mode 100644 index 0000000..4e350ce --- /dev/null +++ b/modules/targets/dvwa/compose.yml @@ -0,0 +1,23 @@ +services: + dvwa: + image: vulnerables/web-dvwa:latest + networks: + - katana-net + environment: + - DB_SERVER=db + depends_on: + - db + + db: + image: mariadb:10.6 + networks: + - katana-net + environment: + - MYSQL_ROOT_PASSWORD=dvwa + - MYSQL_DATABASE=dvwa + - MYSQL_USER=dvwa + - MYSQL_PASSWORD=dvwa + +networks: + katana-net: + external: true diff --git a/modules/targets/dvwa/module.yml b/modules/targets/dvwa/module.yml new file mode 100644 index 0000000..cadac2d --- /dev/null +++ b/modules/targets/dvwa/module.yml @@ -0,0 +1,10 @@ +name: dvwa +category: targets +description: Damn Vulnerable Web Application - PHP/MySQL + +compose: ./compose.yml + +proxy: + - hostname: dvwa + service: dvwa + port: 80 diff --git a/modules/targets/juice-shop.yml b/modules/targets/juice-shop.yml deleted file mode 100644 index d21411b..0000000 --- a/modules/targets/juice-shop.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- - -name: juice-shop -category: targets -description: A rich-featured modern vulnerable app from OWASP, featuring a built-in CTF. -href: https://juice-shop.test:8443 - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install Juice Shop docker container - docker: - name: juice-shop - image: bkimminich/juice-shop - ports: - 3000/tcp: 3000 - - - name: Setup hosts file entries (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 juice-shop.wtf' - - - name: Setup hosts file entries (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 juice-shop.test' - - - name: Create juice-shop nginx config - reverseproxy: - hostname: 'juice-shop.test' - proxy_pass: 'http://localhost:3000' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: juice-shop - - name: Remove juice-shop nginx config - reverseproxy: - hostname: 'juice-shop.test' - - name: Remove hosts file entries (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 juice-shop.wtf' - state: absent - - name: Remove hosts file entries (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 juice-shop.test' - state: absent - - service: - name: nginx - state: restarted - -start: - - service: - name: docker - state: running - - docker: - name: juice-shop - -stop: - - service: - name: docker - state: running - - docker: - name: juice-shop - -status: - running: - started: - docker: juice-shop - -installed: - exists: - docker: juice-shop diff --git a/modules/targets/juiceshop/compose.yml b/modules/targets/juiceshop/compose.yml new file mode 100644 index 0000000..b29ee86 --- /dev/null +++ b/modules/targets/juiceshop/compose.yml @@ -0,0 +1,9 @@ +services: + juiceshop: + image: bkimminich/juice-shop:latest + networks: + - katana-net + +networks: + katana-net: + external: true diff --git a/modules/targets/juiceshop/module.yml b/modules/targets/juiceshop/module.yml new file mode 100644 index 0000000..ff784b1 --- /dev/null +++ b/modules/targets/juiceshop/module.yml @@ -0,0 +1,10 @@ +name: juiceshop +category: targets +description: OWASP Juice Shop - Modern JavaScript vulnerabilities + +compose: ./compose.yml + +proxy: + - hostname: juiceshop + service: juiceshop + port: 3000 diff --git a/modules/targets/k8s-labs.yml b/modules/targets/k8s-labs.yml deleted file mode 100644 index 18f5b39..0000000 --- a/modules/targets/k8s-labs.yml +++ /dev/null @@ -1,148 +0,0 @@ ---- - - name: k8s-labs - category: targets - description: An insecure k8s cluster to hack on. - href: http://k8s-labs.wtf - - install: - - name: Install conntrack in root path - command: - cmd: apt install conntrack - - - name: Get the k8s-labs repo - git: - repo: https://github.com/ProfessionallyEvil/k8s-lab.git - dest: /opt/targets/k8s-labs - - - name: Download minikube bin - get_url: - url: https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 - dest: /usr/bin/minikube - - - name: Make minikube executable - command: - cmd: chmod +x /usr/bin/minikube - - - name: Download kubectl bin - command: - cwd: /usr/bin/ - unsafe: True - shell: True - cmd: "curl -LO https://storage.googleapis.com/kubernetes-release/release/`curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt`/bin/linux/amd64/kubectl" - - - name: Make kubectl executable - command: - cmd: chmod +x /usr/bin/kubectl - - - name: Setup cluster - command: - cwd: /opt/targets/k8s-labs - cmd: /opt/targets/k8s-labs/setup.sh - - - name: Setup web app hosts file entry (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 k8s-labs.wtf' - - - name: Set api hosts file entry (wtf) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 api.k8s-labs.wtf' - - # hacks to make this work more dynamically without supporting state in katana manifests - - name: Write cluster IP to file - command: - unsafe: True - shell: True - cwd: /opt/targets/k8s-labs - cmd: "minikube ip > /opt/targets/k8s-labs/cluster_ip.txt" - - - name: Set up web app nginx reverse-proxy config - copy: - dest: /etc/nginx/conf.d/k8s-labs.conf - content: | - server { - listen 80; - server_name k8s-labs.wtf k8s-labs.test; - location / { - proxy_pass http://{{CLUSTER_IP}}:31380; - } - } - mode: 0644 - - - name: Set up api nginx reverse-proxy config - copy: - dest: /etc/nginx/conf.d/api.k8s-labs.conf - content: | - server { - listen 80; - server_name api.k8s-labs.wtf api.k8s-labs.test; - location / { - proxy_pass http://{{CLUSTER_IP}}:31337; - } - } - mode: 0644 - - - name: Set cluster IP in nginx configs - command: - shell: True - unsafe: True - cmd: sed -i "s/{{CLUSTER_IP}}/$(cat /opt/targets/k8s-labs/cluster_ip.txt)/g" /etc/nginx/conf.d/*k8s-labs.conf - - - service: - name: nginx - state: restarted - - remove: - - name: Remove repo - rm: - path: /opt/targets/k8s-labs - - name: Delete cluster - command: - cmd: minikube delete - - name: Remove images - command: - unsafe: True - shell: True - cmd: docker rmi $(docker images --format '{{.Repository}}:{{.Tag}}' | grep 'k8slabs') - - name: Cleanup .kube dir - rm: - path: /root/.kube - - name: Cleanup .minikube dir - rm: - path: /root/.minikube - - name: Remove minikube - rm: - path: /usr/bin/minikube - - name: Remove kubectl - rm: - path: /usr/bin/kubectl - - name: Remove nginx conf - rm: - path: /etc/nginx/conf.d/k8s-labs.conf - - name: Remove api.k8s-labs nginx conf - rm: - path: /etc/nginx/conf.d/api.k8s-labs.conf - - start: - - service: - name: docker - state: running - - command: - cmd: minikube start --force - - stop: - - service: - name: docker - state: running - - command: - cmd: minikube stop - - status: - running: - started: - docker: minikube - installed: - exists: - docker: minikube diff --git a/modules/targets/musashi.yml b/modules/targets/musashi.yml deleted file mode 100644 index daaeeb4..0000000 --- a/modules/targets/musashi.yml +++ /dev/null @@ -1,165 +0,0 @@ ---- - -name: musashi -category: targets -description: A set of labs for understanding modern application features. -href: https://cors-dojo.test:8443 - -install: - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Install Musashi docker container - docker: - name: musashi - image: ghcr.io/samuraiwtf/musashi-js:latest - env: - CORS_API_PORT: "3020" - CORS_API_HOST: "api.cors.test:3020" - CORS_API_PROXY_PORT: "8443" - CORS_CLIENT_HOST: "cors-dojo.test:3021" - CORS_CLIENT_PORT: "3021" - OAUTH_PROVIDER_PORT: "3030" - OAUTH_CLIENT_PORT: "3031" - CSP_APP_PORT: "3041" - JWT_HOST: "jwt-demo.test:3050" - JWT_PORT: "3050" - USE_TLS: "TRUE" - ports: - 3020/tcp: 3020 # CORS API - 3021/tcp: 3021 # CORS Client - 3030/tcp: 3030 # OAuth Provider - 3031/tcp: 3031 # OAuth Client - 3041/tcp: 3041 # CSP Demo - 3050/tcp: 3050 # JWT Demo - - - name: Setup hosts file entries for CORS API - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 api.cors.test' - - - name: Setup hosts file entries for CORS Client - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 cors-dojo.test' - - - name: Setup hosts file entries for JWT Demo - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 jwt-demo.test' - - - name: Setup hosts file entries for CSP Demo - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 csp-dojo.test' - - - name: Create nginx config for CORS API - reverseproxy: - hostname: 'api.cors.test' - proxy_pass: 'http://localhost:3020' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - name: Create nginx config for CORS Client - reverseproxy: - hostname: 'cors-dojo.test' - proxy_pass: 'http://localhost:3021' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - name: Create nginx config for JWT Demo - reverseproxy: - hostname: 'jwt-demo.test' - proxy_pass: 'http://localhost:3050' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - name: Create nginx config for CSP Demo - reverseproxy: - hostname: 'csp-dojo.test' - proxy_pass: 'http://localhost:3041' - ssl: true - headers: - - 'proxy_set_header X-Forwarded-Proto $scheme' - - 'proxy_set_header X-Forwarded-Port 8443' - - - service: - name: nginx - state: restarted - -remove: - - service: - name: docker - state: running - - docker: - name: musashi - - - name: Remove hosts file entries for CORS API - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 api.cors.test' - state: absent - - - name: Remove hosts file entries for CORS Client - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 cors-dojo.test' - state: absent - - - name: Remove hosts file entries for JWT Demo - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 jwt-demo.test' - state: absent - - - name: Remove hosts file entries for CSP Demo - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 csp-dojo.test' - state: absent - - - name: Remove nginx config for CORS API - reverseproxy: - hostname: 'api.cors.test' - - - name: Remove nginx config for CORS Client - reverseproxy: - hostname: 'cors-dojo.test' - - - name: Remove nginx config for JWT Demo - reverseproxy: - hostname: 'jwt-demo.test' - - - name: Remove nginx config for CSP Demo - reverseproxy: - hostname: 'csp-dojo.test' - -start: - - service: - name: docker - state: running - - docker: - name: musashi - -stop: - - service: - name: docker - state: running - - docker: - name: musashi - -status: - running: - started: - docker: musashi - installed: - exists: - docker: musashi diff --git a/modules/targets/musashi/compose.rendered.yml b/modules/targets/musashi/compose.rendered.yml new file mode 100644 index 0000000..227f5ff --- /dev/null +++ b/modules/targets/musashi/compose.rendered.yml @@ -0,0 +1,19 @@ +services: + musashi: + image: ghcr.io/samuraiwtf/musashi-js:latest + networks: + - katana-net + environment: + - USE_TLS=true + - CORS_CLIENT_PORT=3021 + - CORS_CLIENT_HOST=cors.test + - CORS_API_PORT=3020 + - CORS_API_HOST=api-cors.test + - CORS_API_PROXY_PORT=443 + - CSP_APP_PORT=3041 + - JWT_PORT=3050 + - JWT_HOST=jwt.test + +networks: + katana-net: + external: true diff --git a/modules/targets/musashi/compose.yml b/modules/targets/musashi/compose.yml new file mode 100644 index 0000000..a974aa8 --- /dev/null +++ b/modules/targets/musashi/compose.yml @@ -0,0 +1,19 @@ +services: + musashi: + image: ghcr.io/samuraiwtf/musashi-js:latest + networks: + - katana-net + environment: + - USE_TLS=true + - CORS_CLIENT_PORT=3021 + - CORS_CLIENT_HOST=${CORS_CLIENT_HOST} + - CORS_API_PORT=3020 + - CORS_API_HOST=${CORS_API_HOST} + - CORS_API_PROXY_PORT=443 + - CSP_APP_PORT=3041 + - JWT_PORT=3050 + - JWT_HOST=${JWT_HOST} + +networks: + katana-net: + external: true diff --git a/modules/targets/musashi/module.yml b/modules/targets/musashi/module.yml new file mode 100644 index 0000000..a6544ba --- /dev/null +++ b/modules/targets/musashi/module.yml @@ -0,0 +1,25 @@ +name: musashi +category: targets +description: Musashi.js - CORS, CSP, and JWT security demonstrations + +compose: ./compose.yml + +proxy: + - hostname: cors + service: musashi + port: 3021 + - hostname: api-cors + service: musashi + port: 3020 + - hostname: csp + service: musashi + port: 3041 + - hostname: jwt + service: musashi + port: 3050 + +env: + CORS_CLIENT_HOST: cors + CORS_API_HOST: api-cors + CSP_HOST: csp + JWT_HOST: jwt diff --git a/modules/targets/mutillidae.yml b/modules/targets/mutillidae.yml deleted file mode 100644 index 0ec9302..0000000 --- a/modules/targets/mutillidae.yml +++ /dev/null @@ -1,129 +0,0 @@ ---- - -name: mutillidae -category: targets -description: Test lab focused on OWASP top 10 vulnerabilities. -href: https://mutillidae.test:8443 - -install: - - service: - name: docker - state: running - - - name: Fetch mutillidae dockerhub project - git: - repo: https://github.com/webpwnized/mutillidae-dockerhub.git - dest: /opt/targets/mutillidae - - - name: Remove reference to port 443 - lineinfile: - dest: /opt/targets/mutillidae/docker-compose.yml - line: ' - 127.0.0.1:443:443' - state: absent - - - name: Remove reference to port 80 - lineinfile: - dest: /opt/targets/mutillidae/docker-compose.yml - line: ' - 127.0.0.1:80:80' - state: absent - - - name: Switch www port from 8080 to 33081 - replace: - path: /opt/targets/mutillidae/docker-compose.yml - regexp: "127.0.0.1:8080:80" - replace: "127.0.0.1:33081:80" - - - command: - cmd: docker compose create - cwd: /opt/targets/mutillidae - unsafe: True - shell: True - - - -# - docker: -# name: mutillidae -# image: webpwnized/mutillidae -# ports: -# 80/tcp: 33081 -# 22/tcp: 22222 -# 3306/tcp: 33333 - - - name: Setup hosts file entry (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 mutillidae.test' - - - name: Setup nginx reverse-proxy config - reverseproxy: - hostname: 'mutillidae.test' - proxy_pass: 'http://localhost:33081' - - - name: Create Mutillidae target service descriptor - copy: - dest: /etc/systemd/system/mutillidae.service - content: | - [Unit] - Description=mutillidae target service - After=docker.service - Requires=docker.service - [Service] - Type=oneshot - RemainAfterExit=true - WorkingDirectory=/opt/targets/mutillidae - ExecStart=/usr/bin/docker compose up -d --remove-orphans - ExecStop=/usr/bin/docker compose stop - [Install] - WantedBy=multi-user.target - mode: 0744 - - - - service: - name: nginx - state: restarted - - command: - cmd: systemctl daemon-reload - -remove: - - service: - name: docker - state: running - - service: - name: mutillidae - state: stopped - - command: - cmd: docker compose down - cwd: /opt/targets/mutillidae - unsafe: True - shell: True - - name: Remove hosts file entry (test) - lineinfile: - dest: /etc/hosts - line: '127.0.0.1 mutillidae.test' - state: absent - - rm: - path: - - /etc/systemd/system/mutillidae.service - - name: Remove nginx reverse-proxy config - reverseproxy: - hostname: 'mutillidae.test' - - command: - cmd: systemctl daemon-reload - -start: - - service: - name: mutillidae - state: running - -stop: - - service: - name: mutillidae - state: stopped - -status: - running: - started: - service: mutillidae - installed: - exists: - path: /opt/targets/mutillidae diff --git a/modules/targets/pluginlabs.yml b/modules/targets/pluginlabs.yml deleted file mode 100644 index d6c6e45..0000000 --- a/modules/targets/pluginlabs.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- - -name: plugin-labs -class: provisioners.DockerProvisioner -category: targets -description: Test lab focused on scenarios that require custom plugins or scripts to solve. -source: - git-repo: https://github.com/SamuraiWTF/plugin-labs.git -destination: /opt/targets/plugin-labs -container: - name: plugin-labs - ports: - - host: 33180 - guest: 3000 -hosting: - domain: plugin-labs.wtf - http: - listen: 80 - proxy-pass: http://localhost:33180 - diff --git a/modules/targets/samurai-dojo.yml b/modules/targets/samurai-dojo.yml deleted file mode 100644 index ae3e732..0000000 --- a/modules/targets/samurai-dojo.yml +++ /dev/null @@ -1,198 +0,0 @@ ---- - -name: samurai-dojo -category: targets -description: A basic set of classic apps called dojo-basic and dojo-scavenger. -href: https://dojo-basic.test:8443 - -install: - - name: Turn off docker so we can update DNS if needed - service: - name: docker - state: stopped - - - name: Update docker DNS configuration - copy: - dest: /etc/docker/daemon.json - content: | - { - "dns": ["8.8.8.8", "8.8.4.4"] - } - - - name: Make sure docker service is running - service: - name: docker - state: running - - - name: Fetch dojo-basic and dojo-scavenger docker containers - git: - repo: https://github.com/SamuraiWTF/Samurai-Dojo-legacy.git - dest: /opt/targets/samuraidojo - - - name: Setup dojo-basic database configuration - copy: - dest: /opt/targets/samuraidojo/src/basic/config.inc - content: | - - mode: 0744 - - - name: Remove .htaccess if present - file: - path: /opt/targets/samuraidojo/src/basic/.htaccess - state: absent - - - name: Update dojo-scavenger partners.php links from localhost - replace: - path: /opt/targets/samuraidojo/src/scavenger/partners.php - regexp: 'localhost' - replace: 'scavengerdb' - - - name: Copy scavenger init db script - copy: - dest: /opt/targets/samuraidojo/src/scavenger/init_db.sh - content: | - #!/bin/bash - id=$(sudo docker ps -aqf "name=scavengerdb") - sudo docker cp ./scavenger.sql $id:/ - sudo docker exec $id /bin/sh -c 'mysql -u root -psamurai samurai_dojo_scavenger &2; exit 1; } +warn() { echo "[WARN] $1" >&2; } + +# Check for required tools +command -v curl >/dev/null 2>&1 || error "curl is required but not installed" +command -v jq >/dev/null 2>&1 || error "jq is required but not installed" +command -v tar >/dev/null 2>&1 || error "tar is required but not installed" + +# Detect architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + error "Unsupported architecture: $ARCH" + ;; +esac + +info "Detected architecture: $ARCH" + +# Fetch latest release info from GitHub API +info "Fetching latest ffuf release information..." +RELEASE_JSON=$(curl -sSL https://api.github.com/repos/ffuf/ffuf/releases/latest) || error "Failed to fetch release information" + +# Parse version +VERSION=$(echo "$RELEASE_JSON" | jq -r '.tag_name') || error "Failed to parse version" +info "Latest version: $VERSION" + +# Find download URL for Linux binary +ASSET_NAME="ffuf_.*_linux_${ARCH}.tar.gz" +DOWNLOAD_URL=$(echo "$RELEASE_JSON" | jq -r ".assets[] | select(.name | test(\"$ASSET_NAME\")) | .browser_download_url") || error "Failed to parse download URL" + +if [ -z "$DOWNLOAD_URL" ]; then + error "Could not find download URL for Linux $ARCH" +fi + +info "Download URL: $DOWNLOAD_URL" + +# Check if ffuf is already installed +if [ -f /usr/local/bin/ffuf ]; then + CURRENT_VERSION=$(/usr/local/bin/ffuf -V 2>&1 | head -n1 || echo "unknown") + warn "ffuf is already installed: $CURRENT_VERSION" + warn "Overwriting with $VERSION" +fi + +# Create temporary directory for download +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Download tarball +info "Downloading ffuf ${VERSION}..." +curl -sSL -o "$TEMP_DIR/ffuf.tar.gz" "$DOWNLOAD_URL" || error "Failed to download ffuf" + +# Extract tarball +info "Extracting archive..." +tar -xzf "$TEMP_DIR/ffuf.tar.gz" -C "$TEMP_DIR" || error "Failed to extract archive" + +# Find the ffuf binary in extracted files +FFUF_BINARY=$(find "$TEMP_DIR" -name "ffuf" -type f | head -n1) +if [ -z "$FFUF_BINARY" ]; then + error "Could not find ffuf binary in archive" +fi + +# Move to /usr/local/bin +info "Installing to /usr/local/bin/ffuf..." +mv "$FFUF_BINARY" /usr/local/bin/ffuf || error "Failed to install ffuf" +chmod +x /usr/local/bin/ffuf || error "Failed to set executable permission" + +# Verify installation +if ! /usr/local/bin/ffuf -V >/dev/null 2>&1; then + error "ffuf installation verification failed" +fi + +info "ffuf ${VERSION} installed successfully" + +# Output version for Katana tracking (must be on its own line) +echo "TOOL_VERSION=${VERSION}" diff --git a/modules/tools/ffuf/module.yml b/modules/tools/ffuf/module.yml new file mode 100644 index 0000000..c526189 --- /dev/null +++ b/modules/tools/ffuf/module.yml @@ -0,0 +1,7 @@ +name: ffuf +category: tools +description: Fast web fuzzer written in Go + +install: ./install.sh +remove: ./remove.sh +install_requires_root: true diff --git a/modules/tools/ffuf/remove.sh b/modules/tools/ffuf/remove.sh new file mode 100644 index 0000000..40925f7 --- /dev/null +++ b/modules/tools/ffuf/remove.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# ffuf removal script +# Removes the ffuf binary from /usr/local/bin + +# Color output helpers +info() { echo "[INFO] $1"; } +warn() { echo "[WARN] $1" >&2; } + +# Check if ffuf is installed +if [ ! -f /usr/local/bin/ffuf ]; then + warn "ffuf is not installed at /usr/local/bin/ffuf" + exit 0 +fi + +# Remove the binary +info "Removing /usr/local/bin/ffuf..." +rm -f /usr/local/bin/ffuf + +info "ffuf removed successfully" diff --git a/modules/tools/nikto.yml b/modules/tools/nikto.yml deleted file mode 100644 index c0d8d0d..0000000 --- a/modules/tools/nikto.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- - -name: nikto -category: tools -description: Automated web scanner for legacy web applications. - -install: - - git: - repo: https://github.com/sullo/nikto.git - dest: /opt/samurai/nikto - - - name: Create launcher - copy: - dest: /usr/bin/nikto - content: | - #!/bin/bash - cd /opt/samurai/nikto/program - ./nikto.pl "$@" - mode: 0777 - - - name: Create nikto menu item - copy: - dest: /etc/samurai.d/applications/nikto.desktop - content: | - #!/usr/bin/env xdg-open - - [Desktop Entry] - Version=1.0 - Type=Application - Terminal=false - Exec=mate-terminal --command "bash -c \"nikto -Help;bash\"" - Name=nikto - Icon=utilities-terminal - Categories=samuraiwtf - Comment=Perl-based automated scanner - Name[en_US]=Nikto - mode: 0744 - -remove: - - rm: - path: /opt/samurai/nikto - - - rm: - path: /usr/bin/nikto - - - rm: - path: /etc/samurai.d/applications/nikto.desktop - -status: - installed: - exists: - path: /opt/samurai/nikto \ No newline at end of file diff --git a/modules/tools/nikto/install.sh b/modules/tools/nikto/install.sh new file mode 100644 index 0000000..0b5db9f --- /dev/null +++ b/modules/tools/nikto/install.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# Nikto installation script for Katana +# Installs Nikto from source (Perl-based tool) + +set -e + +info() { echo "[INFO] $1"; } +error() { echo "[ERROR] $1" >&2; exit 1; } +warn() { echo "[WARN] $1" >&2; } + +# Check for required tools +command -v git >/dev/null 2>&1 || error "git is required but not installed. Install it with: sudo apt-get install -y git" +command -v perl >/dev/null 2>&1 || error "perl is required but not installed. Install it with: sudo apt-get install -y perl" + +# Check for Perl SSL module (required for HTTPS scanning) +info "Checking for Perl SSL/TLS support..." +if ! perl -MNet::SSLeay -e 'exit(0)' >/dev/null 2>&1; then + warn "Net::SSLeay Perl module not found. Installing libnet-ssleay-perl..." + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq >/dev/null 2>&1 || warn "apt-get update failed, continuing anyway" + apt-get install -y libnet-ssleay-perl >/dev/null 2>&1 || warn "Could not install libnet-ssleay-perl. Nikto may not work with HTTPS targets." + else + warn "apt-get not available. Please install libnet-ssleay-perl manually for HTTPS support." + fi +fi + +# Installation paths +INSTALL_DIR="/opt/nikto" +WRAPPER_SCRIPT="/usr/local/bin/nikto" + +# Check if nikto is already installed +if [ -d "$INSTALL_DIR" ]; then + info "nikto is already installed at $INSTALL_DIR" + if [ -f "$INSTALL_DIR/program/nikto.pl" ]; then + CURRENT_VERSION=$(grep "\$VARIABLES{'version'}" "$INSTALL_DIR/program/nikto.pl" 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "unknown") + warn "Current version: $CURRENT_VERSION" + fi + warn "Removing old installation..." + rm -rf "$INSTALL_DIR" +fi + +# Clone the Nikto repository +info "Cloning Nikto repository from GitHub..." +if ! git clone --quiet --depth 1 https://github.com/sullo/nikto.git "$INSTALL_DIR" 2>&1; then + error "Failed to clone Nikto repository. Check your internet connection and try again." +fi + +info "Nikto repository cloned to $INSTALL_DIR" + +# Extract version from nikto.pl +if [ -f "$INSTALL_DIR/program/nikto.pl" ]; then + VERSION=$(grep "\$VARIABLES{'version'}" "$INSTALL_DIR/program/nikto.pl" 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "") + if [ -z "$VERSION" ]; then + warn "Could not extract version from nikto.pl, using 'unknown'" + VERSION="unknown" + fi +else + error "nikto.pl not found in cloned repository at $INSTALL_DIR/program/nikto.pl" +fi + +info "Extracted version: $VERSION" + +# Create wrapper script +info "Creating wrapper script at $WRAPPER_SCRIPT..." +cat > "$WRAPPER_SCRIPT" << 'EOF' +#!/bin/bash +# Nikto wrapper script - installed by Katana +exec perl /opt/nikto/program/nikto.pl "$@" +EOF + +chmod +x "$WRAPPER_SCRIPT" + +# Verify installation +info "Verifying nikto installation..." +if [ ! -f "$WRAPPER_SCRIPT" ]; then + error "Wrapper script was not created at $WRAPPER_SCRIPT" +fi + +if [ ! -f "$INSTALL_DIR/program/nikto.pl" ]; then + error "nikto.pl not found at $INSTALL_DIR/program/nikto.pl" +fi + +# Test the wrapper (this will show nikto's usage/version message) +if ! "$WRAPPER_SCRIPT" -Version >/dev/null 2>&1; then + warn "nikto verification test produced an error, but installation may still be successful" +fi + +info "nikto $VERSION installed successfully" +echo "TOOL_VERSION=$VERSION" diff --git a/modules/tools/nikto/module.yml b/modules/tools/nikto/module.yml new file mode 100644 index 0000000..8774f37 --- /dev/null +++ b/modules/tools/nikto/module.yml @@ -0,0 +1,7 @@ +name: nikto +category: tools +description: Web server scanner for security vulnerabilities + +install: ./install.sh +remove: ./remove.sh +install_requires_root: true diff --git a/modules/tools/nikto/remove.sh b/modules/tools/nikto/remove.sh new file mode 100644 index 0000000..7e24b23 --- /dev/null +++ b/modules/tools/nikto/remove.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Nikto removal script for Katana +# Removes Nikto installation from the system + +set -e + +info() { echo "[INFO] $1"; } +warn() { echo "[WARN] $1" >&2; } + +# Installation paths +INSTALL_DIR="/opt/nikto" +WRAPPER_SCRIPT="/usr/local/bin/nikto" + +# Check if nikto is installed +if [ ! -d "$INSTALL_DIR" ] && [ ! -f "$WRAPPER_SCRIPT" ]; then + warn "nikto is not installed" + exit 0 +fi + +# Remove source directory +if [ -d "$INSTALL_DIR" ]; then + info "Removing $INSTALL_DIR..." + rm -rf "$INSTALL_DIR" +fi + +# Remove wrapper script +if [ -f "$WRAPPER_SCRIPT" ]; then + info "Removing $WRAPPER_SCRIPT..." + rm -f "$WRAPPER_SCRIPT" +fi + +info "nikto removed successfully" diff --git a/modules/tools/postman.yml b/modules/tools/postman.yml deleted file mode 100644 index 0423444..0000000 --- a/modules/tools/postman.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- - -name: postman -category: tools -description: Web API development and scripting tool. - - -install: - - name: Download and install and Postman - unarchive: - url: https://dl.pstmn.io/download/latest/linux64 - dest: /opt/samurai/ - - - name: Create Postman launcher - copy: - dest: /usr/bin/postman - content: | - #!/bin/bash - cd /opt/samurai/Postman/app - ./Postman - mode: 0777 - - - name: Create Postman menu item - copy: - dest: /etc/samurai.d/applications/postman.desktop - content: | - #!/usr/bin/env xdg-open - - [Desktop Entry] - Version=1.0 - Type=Application - Terminal=false - Exec=postman - Name=Postman - Icon=/opt/katana/icons/postman-logo.png - Categories=samuraiwtf - Comment=Web Services client - Name[en_US]=Postman - mode: 0744 - -remove: - - rm: - path: /opt/samurai/Postman - - rm: - path: /etc/samurai.d/applications/postman.desktop - - rm: - path: /usr/bin/postman - -status: - installed: - exists: - path: /opt/samurai/Postman \ No newline at end of file diff --git a/modules/tools/sqlmap.yml b/modules/tools/sqlmap.yml deleted file mode 100644 index 0372937..0000000 --- a/modules/tools/sqlmap.yml +++ /dev/null @@ -1,51 +0,0 @@ ---- - -name: sqlmap -category: tools -description: Command line tool to discover and exploit SQLi flaws. - -install: - - name: fetch sqlmap from github - git: - repo: https://github.com/sqlmapproject/sqlmap.git - dest: /opt/samurai/sqlmap - - - name: Create sqlmap launcher - copy: - dest: /usr/bin/sqlmap - content: | - #!/bin/bash - cd /opt/samurai/sqlmap - python3 ./sqlmap.py "$@" - mode: 0777 - - - name: Create sqlmap menu item - copy: - dest: /etc/samurai.d/applications/sqlmap.desktop - content: | - #!/usr/bin/env xdg-open - - [Desktop Entry] - Version=1.0 - Type=Application - Terminal=false - Exec=mate-terminal --command "bash -c \"sqlmap -h;bash\"" - Name=SQLMap - Icon=utilities-terminal - Categories=samuraiwtf - Comment=Open source SQLi discovery and exploitation tool - Name[en_US]=SQLMap - mode: 0744 - -remove: - - rm: - path: /opt/samurai/sqlmap - - rm: - path: /usr/bin/sqlmap - - rm: - path: /etc/samurai.d/applications/sqlmap.desktop - -status: - installed: - exists: - path: /opt/samurai/sqlmap diff --git a/modules/tools/sqlmap/install.sh b/modules/tools/sqlmap/install.sh new file mode 100644 index 0000000..7082296 --- /dev/null +++ b/modules/tools/sqlmap/install.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# sqlmap installation script for Katana +# Installs sqlmap from source (Python-based tool) + +set -e + +info() { echo "[INFO] $1"; } +error() { echo "[ERROR] $1" >&2; exit 1; } +warn() { echo "[WARN] $1" >&2; } + +# Check for required tools +command -v git >/dev/null 2>&1 || error "git is required but not installed. Install it with: sudo apt-get install -y git" +command -v python3 >/dev/null 2>&1 || error "python3 is required but not installed. Install it with: sudo apt-get install -y python3" + +# Installation paths +INSTALL_DIR="/opt/sqlmap" +WRAPPER_SCRIPT="/usr/local/bin/sqlmap" + +# Check if sqlmap is already installed +if [ -d "$INSTALL_DIR" ]; then + info "sqlmap is already installed at $INSTALL_DIR" + if [ -f "$INSTALL_DIR/lib/core/settings.py" ]; then + CURRENT_VERSION=$(grep -oP 'VERSION\s*=\s*"\K[0-9.]+(?=")' "$INSTALL_DIR/lib/core/settings.py" 2>/dev/null || echo "unknown") + warn "Current version: $CURRENT_VERSION" + fi + warn "Removing old installation..." + rm -rf "$INSTALL_DIR" +fi + +# Clone the sqlmap repository +info "Cloning sqlmap repository from GitHub..." +if ! git clone --quiet --depth 1 https://github.com/sqlmapproject/sqlmap.git "$INSTALL_DIR" 2>&1; then + error "Failed to clone sqlmap repository. Check your internet connection and try again." +fi + +info "sqlmap repository cloned to $INSTALL_DIR" + +# Extract version from lib/core/settings.py +if [ -f "$INSTALL_DIR/lib/core/settings.py" ]; then + VERSION=$(grep -oP 'VERSION\s*=\s*"\K[0-9.]+(?=")' "$INSTALL_DIR/lib/core/settings.py" 2>/dev/null || echo "") + if [ -z "$VERSION" ]; then + warn "Could not extract version from lib/core/settings.py, using 'unknown'" + VERSION="unknown" + fi +else + error "lib/core/settings.py not found in cloned repository at $INSTALL_DIR/lib/core/settings.py" +fi + +info "Extracted version: $VERSION" + +# Create wrapper script +info "Creating wrapper script at $WRAPPER_SCRIPT..." +cat > "$WRAPPER_SCRIPT" << 'EOF' +#!/bin/bash +# sqlmap wrapper script - installed by Katana +exec python3 /opt/sqlmap/sqlmap.py "$@" +EOF + +chmod +x "$WRAPPER_SCRIPT" + +# Verify installation +info "Verifying sqlmap installation..." +if [ ! -f "$WRAPPER_SCRIPT" ]; then + error "Wrapper script was not created at $WRAPPER_SCRIPT" +fi + +if [ ! -f "$INSTALL_DIR/sqlmap.py" ]; then + error "sqlmap.py not found at $INSTALL_DIR/sqlmap.py" +fi + +# Test the wrapper (this will show sqlmap's usage/version message) +if ! "$WRAPPER_SCRIPT" --version >/dev/null 2>&1; then + warn "sqlmap verification test produced an error, but installation may still be successful" +fi + +info "sqlmap $VERSION installed successfully" +echo "TOOL_VERSION=$VERSION" diff --git a/modules/tools/sqlmap/module.yml b/modules/tools/sqlmap/module.yml new file mode 100644 index 0000000..581f5d0 --- /dev/null +++ b/modules/tools/sqlmap/module.yml @@ -0,0 +1,7 @@ +name: sqlmap +category: tools +description: Automatic SQL injection and database takeover tool + +install: ./install.sh +remove: ./remove.sh +install_requires_root: true diff --git a/modules/tools/sqlmap/remove.sh b/modules/tools/sqlmap/remove.sh new file mode 100644 index 0000000..59d2531 --- /dev/null +++ b/modules/tools/sqlmap/remove.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# sqlmap removal script for Katana +# Removes sqlmap installation from the system + +set -e + +info() { echo "[INFO] $1"; } +warn() { echo "[WARN] $1" >&2; } + +# Installation paths +INSTALL_DIR="/opt/sqlmap" +WRAPPER_SCRIPT="/usr/local/bin/sqlmap" + +# Check if sqlmap is installed +if [ ! -d "$INSTALL_DIR" ] && [ ! -f "$WRAPPER_SCRIPT" ]; then + warn "sqlmap is not installed" + exit 0 +fi + +# Remove source directory +if [ -d "$INSTALL_DIR" ]; then + info "Removing $INSTALL_DIR..." + rm -rf "$INSTALL_DIR" +fi + +# Remove wrapper script +if [ -f "$WRAPPER_SCRIPT" ]; then + info "Removing $WRAPPER_SCRIPT..." + rm -f "$WRAPPER_SCRIPT" +fi + +info "sqlmap removed successfully" diff --git a/modules/tools/trufflehog.yml b/modules/tools/trufflehog.yml deleted file mode 100644 index 2275255..0000000 --- a/modules/tools/trufflehog.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- - -name: trufflehog -category: tools -description: Tool to find leaked credentials in GitHub. - - -install: - - name: Download and install and trufflehog - unarchive: - url: https://github.com/trufflesecurity/trufflehog/releases/download/v3.88.5/trufflehog_3.88.5_linux_amd64.tar.gz - dest: /usr/local/bin - - -remove: - - rm: - path: /usr/local/bin/trufflehog - -status: - installed: - exists: - path: /usr/local/bin/trufflehog \ No newline at end of file diff --git a/modules/tools/trufflehog/install.sh b/modules/tools/trufflehog/install.sh new file mode 100644 index 0000000..b89cf4d --- /dev/null +++ b/modules/tools/trufflehog/install.sh @@ -0,0 +1,89 @@ +#!/bin/bash +set -e + +# trufflehog installation script +# Downloads and installs the latest trufflehog release from GitHub + +# Color output helpers +info() { echo "[INFO] $1"; } +error() { echo "[ERROR] $1" >&2; exit 1; } +warn() { echo "[WARN] $1" >&2; } + +# Check for required tools +command -v curl >/dev/null 2>&1 || error "curl is required but not installed" +command -v jq >/dev/null 2>&1 || error "jq is required but not installed" +command -v tar >/dev/null 2>&1 || error "tar is required but not installed" + +# Detect architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + error "Unsupported architecture: $ARCH" + ;; +esac + +info "Detected architecture: $ARCH" + +# Fetch latest release info from GitHub API +info "Fetching latest trufflehog release information..." +RELEASE_JSON=$(curl -sSL https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest) || error "Failed to fetch release information" + +# Parse version +VERSION=$(echo "$RELEASE_JSON" | jq -r '.tag_name') || error "Failed to parse version" +info "Latest version: $VERSION" + +# Find download URL for Linux binary +ASSET_NAME="trufflehog_.*_linux_${ARCH}.tar.gz" +DOWNLOAD_URL=$(echo "$RELEASE_JSON" | jq -r ".assets[] | select(.name | test(\"$ASSET_NAME\")) | .browser_download_url") || error "Failed to parse download URL" + +if [ -z "$DOWNLOAD_URL" ]; then + error "Could not find download URL for Linux $ARCH" +fi + +info "Download URL: $DOWNLOAD_URL" + +# Check if trufflehog is already installed +if [ -f /usr/local/bin/trufflehog ]; then + CURRENT_VERSION=$(/usr/local/bin/trufflehog --version 2>&1 | head -n1 || echo "unknown") + warn "trufflehog is already installed: $CURRENT_VERSION" + warn "Overwriting with $VERSION" +fi + +# Create temporary directory for download +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Download tarball +info "Downloading trufflehog ${VERSION}..." +curl -sSL -o "$TEMP_DIR/trufflehog.tar.gz" "$DOWNLOAD_URL" || error "Failed to download trufflehog" + +# Extract tarball +info "Extracting archive..." +tar -xzf "$TEMP_DIR/trufflehog.tar.gz" -C "$TEMP_DIR" || error "Failed to extract archive" + +# Find the trufflehog binary in extracted files +TRUFFLEHOG_BINARY=$(find "$TEMP_DIR" -name "trufflehog" -type f | head -n1) +if [ -z "$TRUFFLEHOG_BINARY" ]; then + error "Could not find trufflehog binary in archive" +fi + +# Move to /usr/local/bin +info "Installing to /usr/local/bin/trufflehog..." +mv "$TRUFFLEHOG_BINARY" /usr/local/bin/trufflehog || error "Failed to install trufflehog" +chmod +x /usr/local/bin/trufflehog || error "Failed to set executable permission" + +# Verify installation +if ! /usr/local/bin/trufflehog --version >/dev/null 2>&1; then + error "trufflehog installation verification failed" +fi + +info "trufflehog ${VERSION} installed successfully" + +# Output version for Katana tracking (must be on its own line) +echo "TOOL_VERSION=${VERSION}" diff --git a/modules/tools/trufflehog/module.yml b/modules/tools/trufflehog/module.yml new file mode 100644 index 0000000..421743c --- /dev/null +++ b/modules/tools/trufflehog/module.yml @@ -0,0 +1,7 @@ +name: trufflehog +category: tools +description: Find credentials in git repositories, filesystems, and cloud services + +install: ./install.sh +remove: ./remove.sh +install_requires_root: true diff --git a/modules/tools/trufflehog/remove.sh b/modules/tools/trufflehog/remove.sh new file mode 100644 index 0000000..1769ea0 --- /dev/null +++ b/modules/tools/trufflehog/remove.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# trufflehog removal script +# Removes the trufflehog binary from /usr/local/bin + +# Color output helpers +info() { echo "[INFO] $1"; } +warn() { echo "[WARN] $1" >&2; } + +# Check if trufflehog is installed +if [ ! -f /usr/local/bin/trufflehog ]; then + warn "trufflehog is not installed at /usr/local/bin/trufflehog" + exit 0 +fi + +# Remove the binary +info "Removing /usr/local/bin/trufflehog..." +rm -f /usr/local/bin/trufflehog + +info "trufflehog removed successfully" diff --git a/modules/tools/wordlists.yml b/modules/tools/wordlists.yml deleted file mode 100644 index d1b66ef..0000000 --- a/modules/tools/wordlists.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- - -name: wordlists -category: tools -description: Word lists to be used in fuzzing attacks. - -install: - - name: Create wordlist folder - file: - path: /opt/samurai/wordlists - state: directory - - - name: Install FuzzDB - git: - repo: https://github.com/fuzzdb-project/fuzzdb.git - dest: /opt/samurai/wordlists/fuzzdb - - - name: Install SecLists - git: - repo: https://github.com/danielmiessler/SecLists.git - dest: /opt/samurai/wordlists/seclists - -remove: - - rm: - path: /opt/samurai/wordlists - -status: - installed: - exists: - path: /opt/samurai/wordlists \ No newline at end of file diff --git a/modules/tools/wordlists/install.sh b/modules/tools/wordlists/install.sh new file mode 100644 index 0000000..6053ea2 --- /dev/null +++ b/modules/tools/wordlists/install.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +# wordlists (SecLists) installation script for Katana +# Installs SecLists wordlist collection from GitHub + +# Color output helpers +info() { echo "[INFO] $1"; } +error() { echo "[ERROR] $1" >&2; exit 1; } +warn() { echo "[WARN] $1" >&2; } + +# Check for required tools +command -v git >/dev/null 2>&1 || error "git is required but not installed. Install it with: sudo apt-get install -y git" + +# Installation paths +INSTALL_DIR="/usr/share/wordlists" +SECLISTS_SYMLINK="/usr/share/seclists" +OPT_SYMLINK="/opt/wordlists" + +# Check if wordlists are already installed +if [ -d "$INSTALL_DIR" ]; then + info "wordlists are already installed at $INSTALL_DIR" + if [ -d "$INSTALL_DIR/.git" ]; then + CURRENT_VERSION=$(git -C "$INSTALL_DIR" describe --tags 2>/dev/null || git -C "$INSTALL_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") + warn "Current version: $CURRENT_VERSION" + fi + warn "Removing old installation..." + rm -rf "$INSTALL_DIR" +fi + +# Clone the SecLists repository (shallow clone for speed) +info "Cloning SecLists repository from GitHub (this may take 2-3 minutes)..." +if ! git clone --quiet --depth 1 https://github.com/danielmiessler/SecLists.git "$INSTALL_DIR" 2>&1; then + error "Failed to clone SecLists repository. Check your internet connection and try again." +fi + +info "SecLists repository cloned to $INSTALL_DIR" + +# Extract version from git tags or commit +if [ -d "$INSTALL_DIR/.git" ]; then + VERSION=$(git -C "$INSTALL_DIR" describe --tags 2>/dev/null || git -C "$INSTALL_DIR" rev-parse --short HEAD 2>/dev/null || echo "") + if [ -z "$VERSION" ]; then + warn "Could not extract version from git, using 'unknown'" + VERSION="unknown" + fi +else + error ".git directory not found in cloned repository at $INSTALL_DIR/.git" +fi + +info "Extracted version: $VERSION" + +# Create symlinks for compatibility +info "Creating compatibility symlinks..." + +# Remove existing symlinks if they exist +[ -L "$SECLISTS_SYMLINK" ] && rm -f "$SECLISTS_SYMLINK" +[ -L "$OPT_SYMLINK" ] && rm -f "$OPT_SYMLINK" + +# Create /usr/share/seclists -> /usr/share/wordlists (Kali convention) +ln -s "$INSTALL_DIR" "$SECLISTS_SYMLINK" || warn "Failed to create symlink at $SECLISTS_SYMLINK" +info "Created symlink: $SECLISTS_SYMLINK -> $INSTALL_DIR" + +# Create /opt/wordlists -> /usr/share/wordlists (Katana consistency) +ln -s "$INSTALL_DIR" "$OPT_SYMLINK" || warn "Failed to create symlink at $OPT_SYMLINK" +info "Created symlink: $OPT_SYMLINK -> $INSTALL_DIR" + +# Verify installation +info "Verifying wordlists installation..." +if [ ! -d "$INSTALL_DIR" ]; then + error "Installation directory was not created at $INSTALL_DIR" +fi + +if [ ! -d "$INSTALL_DIR/Passwords" ]; then + warn "Expected Passwords directory not found - installation may be incomplete" +fi + +info "wordlists (SecLists) $VERSION installed successfully" +echo "TOOL_VERSION=$VERSION" diff --git a/modules/tools/wordlists/module.yml b/modules/tools/wordlists/module.yml new file mode 100644 index 0000000..a759ccd --- /dev/null +++ b/modules/tools/wordlists/module.yml @@ -0,0 +1,7 @@ +name: wordlists +category: tools +description: SecLists collection of wordlists for security testing (usernames, passwords, URLs, fuzzing payloads) + +install: ./install.sh +remove: ./remove.sh +install_requires_root: true diff --git a/modules/tools/wordlists/remove.sh b/modules/tools/wordlists/remove.sh new file mode 100644 index 0000000..24987b2 --- /dev/null +++ b/modules/tools/wordlists/remove.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e + +# wordlists (SecLists) removal script for Katana +# Removes SecLists wordlist collection from the system + +# Color output helpers +info() { echo "[INFO] $1"; } +warn() { echo "[WARN] $1" >&2; } + +# Installation paths +INSTALL_DIR="/usr/share/wordlists" +SECLISTS_SYMLINK="/usr/share/seclists" +OPT_SYMLINK="/opt/wordlists" + +# Check if wordlists are installed +if [ ! -d "$INSTALL_DIR" ] && [ ! -L "$SECLISTS_SYMLINK" ] && [ ! -L "$OPT_SYMLINK" ]; then + warn "wordlists are not installed" + exit 0 +fi + +# Remove main installation directory +if [ -d "$INSTALL_DIR" ]; then + # Safety check: verify it's actually a git repo before removing + if [ -d "$INSTALL_DIR/.git" ]; then + info "Removing $INSTALL_DIR..." + rm -rf "$INSTALL_DIR" + else + warn "$INSTALL_DIR exists but is not a git repository - skipping removal for safety" + fi +fi + +# Remove symlinks +if [ -L "$SECLISTS_SYMLINK" ]; then + info "Removing $SECLISTS_SYMLINK..." + rm -f "$SECLISTS_SYMLINK" +fi + +if [ -L "$OPT_SYMLINK" ]; then + info "Removing $OPT_SYMLINK..." + rm -f "$OPT_SYMLINK" +fi + +info "wordlists removed successfully" diff --git a/modules/tools/zap.yml b/modules/tools/zap.yml deleted file mode 100644 index 5c15038..0000000 --- a/modules/tools/zap.yml +++ /dev/null @@ -1,40 +0,0 @@ ---- - -name: zap -category: tools -description: Open source interception proxy for web pentesting. - -install: - - unarchive: - url: https://github.com/zaproxy/zaproxy/releases/download/v2.16.0/ZAP_2.16.0_Linux.tar.gz - dest: /opt/samurai/ - cleanup: true - - - desktop: - desktop_file: - filename: zap.desktop - content: | - #!/usr/bin/env xdg-open - - [Desktop Entry] - Version=1.0 - Type=Application - Terminal=false - Exec=/opt/samurai/ZAP_2.16.0/zap.sh - Name=ZAP 2.16 - Icon=/opt/katana/icons/zap.png - Categories=samuraiwtf - Comment=OWASP Interception proxy - Name[en_US]=ZAP - add_to_favorites: true - -remove: - - desktop: - filename: zap.desktop - - rm: - path: /tmp/ZAP_2.16.0_Linux.tar.gz - -status: - installed: - exists: - path: /opt/samurai/ZAP_2.16.0 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..096f2b1 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "katana", + "version": "2.0.0", + "description": "OWASP SamuraiWTF lab management solution", + "type": "module", + "main": "src/cli.ts", + "scripts": { + "dev": "bun run src/cli.ts", + "build:ui": "bun run src/ui/build.ts", + "build": "bun run build:ui && bun build --compile src/cli.ts --outfile bin/katana", + "test": "bun test", + "lint": "bunx biome check src/", + "format": "bunx biome format --write src/" + }, + "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "commander": "^11.1.0", + "dockerode": "^4.0.0", + "lucide-react": "^0.468.0", + "next-themes": "^0.4.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", + "yaml": "^2.3.4", + "zod": "^3.22.4" + }, + "devDependencies": { + "@biomejs/biome": "^1.4.1", + "@types/bun": "latest", + "@types/dockerode": "^3.3.23", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/plugins/Command.py b/plugins/Command.py deleted file mode 100644 index 8b398ae..0000000 --- a/plugins/Command.py +++ /dev/null @@ -1,41 +0,0 @@ -from plugins import Plugin - -import subprocess -import shlex -import os.path -import os - - -class Command(Plugin): - ''' - module: command - options: - cmd: - type: str - description: - - the command to run - shell: - type: bool - description: - - When true the command is executed with a shell, allowing for shell expansions to work. - unsafe: - type: bool - description: - - When true the command string is passed straight into the subprocess call. - ''' - @classmethod - def get_aliases(cls): - return ["command"] - - def any(self, params): - self._validate_params(params, ['cmd'], 'command') - - command_list = params.get('cmd') - shell = params.get('shell') - if not params.get('unsafe'): - command_list = shlex.split(command_list) - results = subprocess.run(command_list, shell=shell, cwd=params.get('cwd')) - - return True, None - - # start_status_code = subprocess.call(['systemctl', 'start', params.get('name')]) \ No newline at end of file diff --git a/plugins/Copy.py b/plugins/Copy.py deleted file mode 100644 index 5710903..0000000 --- a/plugins/Copy.py +++ /dev/null @@ -1,23 +0,0 @@ -from plugins import Plugin -import os.path -import os - - -class Copy(Plugin): - - @classmethod - def get_aliases(cls): - return ["copy"] - - def any(self, params): - self._validate_params(params, ['dest', 'content'], 'copy') - if not params.get("force", False) and os.path.exists(params.get("dest")): - return False, "The specified destination path exists: {}".format(params.get("dest")) - else: - with open(params.get("dest"), 'w') as out_file: - out_file.write(params.get('content')) - if "mode" in params: - os.chmod(params.get("dest"), params.get("mode")) - - return True, None - diff --git a/plugins/DesktopIntegration.py b/plugins/DesktopIntegration.py deleted file mode 100644 index 4338860..0000000 --- a/plugins/DesktopIntegration.py +++ /dev/null @@ -1,267 +0,0 @@ -import os -import pwd -import subprocess -import shutil -import time -from pathlib import Path -from typing import Dict, Any, Optional, Tuple -from .Plugin import Plugin - -class DesktopIntegration(Plugin): - """Plugin for handling desktop integration tasks like menu items and favorites.""" - - @classmethod - def get_aliases(cls): - return ['desktop'] - - def __init__(self): - super().__init__() - # Get the real user's home directory (not root's) - sudo_user = os.environ.get('SUDO_USER', 'samurai') # Default to samurai if not set - try: - self.user_home = Path(pwd.getpwnam(sudo_user).pw_dir) - self.real_user = sudo_user - except KeyError: - self.user_home = Path('/home/samurai') # Fallback to samurai - self.real_user = 'samurai' - - self.apps_dir = self.user_home / '.local/share/applications' - self.apps_dir.mkdir(parents=True, exist_ok=True) - # Ensure correct ownership - if os.geteuid() == 0: # If running as root - uid = pwd.getpwnam(self.real_user).pw_uid - gid = pwd.getpwnam(self.real_user).pw_gid - os.chown(self.apps_dir, uid, gid) - - def _run_as_user(self, cmd: list, check: bool = True) -> subprocess.CompletedProcess: - """Run a command as the real user instead of root.""" - if os.geteuid() == 0: # If we're root - cmd = ['runuser', '-u', self.real_user, '--'] + cmd - return subprocess.run(cmd, check=check, text=True, capture_output=True) - - def _run_gsettings_command(self, args: list) -> subprocess.CompletedProcess: - """Run a gsettings command.""" - cmd = ['gsettings'] + args - if os.geteuid() == 0: # If we're root - cmd = ['runuser', '-u', self.real_user, '--'] + cmd - return subprocess.run(cmd, check=False, text=True, capture_output=True) - - def _is_supported_environment(self) -> bool: - """Check if we're in a supported environment.""" - return os.name == 'posix' - - def _update_desktop_database(self): - """Update the desktop database if possible.""" - try: - # Update both system and user databases - if os.geteuid() == 0: - # System-wide update - subprocess.run(['update-desktop-database'], check=True) - - # User-specific update - self._run_as_user(['update-desktop-database', str(self.apps_dir)], check=True) - - # Also try updating the cached applications - self._run_as_user(['gtk-update-icon-cache', '-f', '-t', str(self.user_home / '.local/share/icons')], check=False) - self._run_as_user(['xdg-desktop-menu', 'forceupdate'], check=False) - except (subprocess.SubprocessError, FileNotFoundError) as e: - print(f"Warning: desktop database update failed: {str(e)}") - - def _validate_desktop_file(self, content: str) -> Tuple[bool, Optional[str]]: - """Validate desktop file content.""" - required_fields = ['Type', 'Name', 'Exec'] - missing = [field for field in required_fields if f"{field}=" not in content] - if missing: - return False, f"Missing required fields: {', '.join(missing)}" - return True, None - - def _update_favorites(self, filename: str, add: bool = True) -> Tuple[bool, Optional[str]]: - """Update GNOME favorites using gsettings.""" - try: - # Get current favorites - result = self._run_gsettings_command(['get', 'org.gnome.shell', 'favorite-apps']) - if result.returncode != 0: - return False, "Failed to get current favorites" - - # Add a short delay after reading - time.sleep(1) - - # Parse the current favorites string into a list - try: - # The output is typically in the format: ['app1.desktop', 'app2.desktop'] - # Or @as [] for an empty list - current = result.stdout.strip() - if current == '@as []': - current_favs = [] - else: - if current.startswith('[') and current.endswith(']'): - current = current[1:-1] # Remove [ and ] - # Split and clean each item, filtering out empty strings - current_favs = [x.strip("' ") for x in current.split(',') if x.strip("' ")] - # Remove any empty strings that might have slipped through - current_favs = [x for x in current_favs if x] - except Exception as e: - print(f"Warning: Error parsing favorites ({str(e)}), starting with empty list") - current_favs = [] - - # Update favorites list and track changes - changed = False - # print(f"Current favorites: {current_favs}") - - if add: - if filename not in current_favs: - current_favs.append(filename) - changed = True - # print(f"Adding {filename} to favorites") - else: - print(f"Note: {filename} is already in favorites") - else: - if filename in current_favs: - current_favs = [x for x in current_favs if x != filename] - changed = True - # print(f"Removing {filename} from favorites") - else: - print(f"Note: {filename} was not in favorites") - - # Convert to gsettings format and update - # Ensure each item is properly quoted - favs_str = "[" + ", ".join(f"'{x}'" for x in current_favs) + "]" - # print(f"Setting favorites to: {favs_str}") - - # Add a short delay before setting - time.sleep(1) - - # Always run the set command due to gsettings caching - result = self._run_gsettings_command(['set', 'org.gnome.shell', 'favorite-apps', favs_str]) - - # Add a short delay after setting - time.sleep(1) - - if result.returncode == 0: - if changed: - return True, "Updated GNOME favorites" - else: - return True, "Refreshed GNOME favorites (no changes needed)" - return False, "Failed to update favorites" - - except subprocess.SubprocessError as e: - return False, f"Failed to update favorites: {str(e)}" - - def install(self, params: Dict[str, Any]) -> Tuple[bool, Optional[str]]: - """Install a desktop file and optionally add to favorites.""" - required_params = ['desktop_file'] - self._validate_params(params, required_params, 'desktop') - - if not self._is_supported_environment(): - return False, "Not a supported environment (requires POSIX)" - - desktop_file = params.get('desktop_file') - if not desktop_file: - return False, "No desktop file configuration provided" - - content = desktop_file.get('content', '') - filename = desktop_file.get('filename') - add_to_favorites = desktop_file.get('add_to_favorites', False) - - if not content or not filename: - return False, "Missing required desktop file content or filename" - - # Validate desktop file content - is_valid, error = self._validate_desktop_file(content) - if not is_valid: - return False, f"Invalid desktop file: {error}" - - # Ensure filename has .desktop extension - if not filename.endswith('.desktop'): - filename += '.desktop' - - # Write desktop file - desktop_path = self.apps_dir / filename - changed = False - msg_parts = [] - - try: - # Check if content is different - if desktop_path.exists(): - old_content = desktop_path.read_text() - if old_content == content: - msg_parts.append("Desktop file unchanged") - else: - changed = True - else: - changed = True - - if changed: - desktop_path.write_text(content) - desktop_path.chmod(0o755) - # Ensure correct ownership - if os.geteuid() == 0: # If running as root - uid = pwd.getpwnam(self.real_user).pw_uid - gid = pwd.getpwnam(self.real_user).pw_gid - os.chown(desktop_path, uid, gid) - msg_parts.append("Desktop file created/updated") - - # Try using xdg-desktop-menu as the real user - try: - result = self._run_as_user(['xdg-desktop-menu', 'install', '--novendor', str(desktop_path)]) - msg_parts.append("Registered with desktop menu") - changed = True - except subprocess.SubprocessError as e: - self._update_desktop_database() - msg_parts.append("Updated desktop database") - - # Add to favorites if requested - if add_to_favorites: - fav_changed, fav_msg = self._update_favorites(filename, add=True) - if fav_changed: - changed = True - if fav_msg: - msg_parts.append(fav_msg) - - return changed, "; ".join(msg_parts) - except Exception as e: - return False, f"Failed to install desktop file: {str(e)}" - - def remove(self, params: Dict[str, Any]) -> Tuple[bool, Optional[str]]: - """Remove a desktop file and from favorites if present.""" - required_params = ['filename'] - self._validate_params(params, required_params, 'desktop') - - if not self._is_supported_environment(): - return False, "Not a supported environment (requires POSIX)" - - filename = params.get('filename') - if not filename: - return False, "No filename provided" - - if not filename.endswith('.desktop'): - filename += '.desktop' - - desktop_path = self.apps_dir / filename - changed = False - msg_parts = [] - - try: - # Try using xdg-desktop-menu first - try: - self._run_as_user(['xdg-desktop-menu', 'uninstall', '--novendor', str(desktop_path)]) - msg_parts.append("Unregistered from desktop menu") - changed = True - except subprocess.SubprocessError: - if desktop_path.exists(): - desktop_path.unlink() - changed = True - msg_parts.append("Removed desktop file") - self._update_desktop_database() - msg_parts.append("Updated desktop database") - - # Remove from favorites if present - fav_changed, fav_msg = self._update_favorites(filename, add=False) - if fav_changed: - changed = True - if fav_msg: - msg_parts.append(fav_msg) - - return changed, "; ".join(msg_parts) if msg_parts else None - except Exception as e: - return False, f"Failed to remove desktop file: {str(e)}" diff --git a/plugins/Docker.py b/plugins/Docker.py deleted file mode 100644 index 1f5b9c7..0000000 --- a/plugins/Docker.py +++ /dev/null @@ -1,84 +0,0 @@ -from plugins import Plugin -import docker -import katanaerrors - - -class Docker(Plugin): - - @classmethod - def get_aliases(cls): - return ["docker"] - - def install(self, params): - self._validate_params(params, ['name', 'image'], 'docker') - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - - container_list = client.containers.list(filters={'name': params.get('name')}, all=True) - - port_mappings = {} - for container_port in params.get('ports').keys(): - port_mappings[container_port] = ('127.0.0.1', params.get('ports').get(container_port)) - - if len(container_list) > 0: - return False, "A container named '{}' is already installed.".format(params.get('name')) - else: - images = client.images.list(name=params.get('image')) - if len(images) == 0: - if params.get('path') is None: - print(" Image not available locally. Pulling image from DockerHub: " + params.get('image')) - the_image = client.images.pull(params.get('image')) - if isinstance(the_image, list): - image_id = the_image[0].id - else: - image_id = the_image.id - else: - print(" Building image locally at {}".format(params.get('path'))) - image_id = client.images.build(path=params.get('path'), tag=f'{params.get("name")}:local', forcerm=True)[0].id - print(f'Image id: {image_id}') - container = client.containers.create(image=image_id, name=params.get('name'), detach=True, - ports=port_mappings) - container.logs() - return True, None - - def remove(self, params): - self._validate_params(params, ['name'], 'docker') - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - container_list = client.containers.list(filters={'name': params.get('name')}, all=True) - - if len(container_list) == 0: - return False, "No container named '{}' was found. It will need to be installed before you can remove it.".format( - params.get('name')) - elif container_list[0].status == "running": - raise katanaerrors.CriticalFunctionFailure('docker', 'Cannot remove a running container.') - else: - container_list[0].remove(v=True) - client.images.prune() - return True, "Container removed: '{}".format(params.get('name')) - - def start(self, params): - self._validate_params(params, ['name'], 'docker') - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - container_list = client.containers.list(filters={'name': params.get('name')}, all=True) - - if len(container_list) == 0: - return False, "No container named '{}' was found. It will need to be installed before you can start it.".format( - params.get('name')) - elif container_list[0].status == "running": - return False, "The '{}' container is already running.".format(params.get('name')) - else: - container_list[0].start() - return True, None - - def stop(self, params): - self._validate_params(params, ['name'], 'docker') - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - container_list = client.containers.list(filters={'name': params.get('name')}, all=True) - - if len(container_list) == 0: - return False, "No container named '{}' was found. It will need to be installed before you can stop it.".format( - params.get('name')) - elif container_list[0].status != "running": - return False, "The '{}' container is not running.".format(params.get('name')) - else: - container_list[0].stop() - return True, None diff --git a/plugins/Exists.py b/plugins/Exists.py deleted file mode 100644 index cccd1d7..0000000 --- a/plugins/Exists.py +++ /dev/null @@ -1,28 +0,0 @@ -from plugins import Plugin -import os.path -import subprocess -import docker -import katanaerrors - - -class Exists(Plugin): - - @classmethod - def get_aliases(cls): - return ["exists"] - - def path(self, value): - return os.path.exists(value) - - def service(self, value): - return_code = subprocess.call(['systemctl', 'status', value, '--no-pager']) - return return_code != 4 - - def docker(self, value): - if subprocess.call(['systemctl', 'status', 'docker', '--no-pager']) == 0: - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - container_list = client.containers.list(filters={'name': value}, all=True) - - return len(container_list) > 0 - else: - raise katanaerrors.BlockedByDependencyException('docker') diff --git a/plugins/File.py b/plugins/File.py deleted file mode 100644 index 0a742f4..0000000 --- a/plugins/File.py +++ /dev/null @@ -1,33 +0,0 @@ -from plugins import Plugin -from plugins import Remove -import os -import katanaerrors - - -class File(Plugin): - - @classmethod - def get_aliases(cls): - return ["file"] - - def any(self, params): - self._validate_params(params, ['path', 'state'], 'file') - - state = params.get('state') - if state not in ['directory', 'absent']: - raise katanaerrors.UnrecognizedParamValue('state', state, 'file', 'directory') - - if state == 'directory': - try: - if params.get('mode') is None: - os.makedirs(params.get('path')) - return True, None - else: - os.makedirs(params.get('path'), mode=params.get('mode')) - return True, None - except FileExistsError as err: - return False, 'Specified path exists.' - elif state == 'absent': - remove = Remove() - remove_params = {'path': params.get('path')} - return remove.any(remove_params) diff --git a/plugins/GetUrl.py b/plugins/GetUrl.py deleted file mode 100644 index 7795c4c..0000000 --- a/plugins/GetUrl.py +++ /dev/null @@ -1,75 +0,0 @@ -from plugins import Plugin, Unarchive -import requests -import os -import os.path -import re -from urllib.parse import urlparse -import katanaerrors - - -def _get_link_from_page(url, link_pattern): - response = requests.get(url) - print("Fetched response as code={}".format(response.status_code)) - urls = re.findall(link_pattern, response.text) - - print("URLs {}".format(urls)) - - if len(urls) > 0: - if urls[0].startswith("http"): - return urls[0] - else: - parsed_uri = urlparse(response.url) - return '{}://{}{}'.format(parsed_uri.scheme, parsed_uri.netloc, urls[0]) - - else: - # TODO: there's probably a better way to do this - # This is specifically to address GitHub's custom HTML tag - # 'expanded_assets' is a line present in several GH Releases pages, - # so I figured it would work pretty well. - if 'expanded_assets' in response.text: - regex_find_src = '.*expanded_assets.*' - find_src = re.findall(regex_find_src, response.text) - for line in find_src: - find_assets = line.split('"') - if len(find_assets) > 0: - for r in range(len(find_assets)): - if find_assets[r].startswith('http'): - return _get_link_from_page(find_assets[r], link_pattern) - - print(response.text) - raise katanaerrors.CriticalFunctionFailure('get_url', 'Could not find link pattern in resulting page.') - - -class GetUrl(Plugin): - - @classmethod - def get_aliases(cls): - return ["get_url"] - - def any(self, params): - self._validate_params(params, ['url', 'dest'], 'get_url') - - if os.path.exists(params.get('dest')) and not params.get('overwrite', False): - return False, 'The specified file already exists: {}'.format(params.get('dest')) - else: - link_pattern = params.get('link_pattern') - - if link_pattern is not None: - url = _get_link_from_page(params.get('url'), link_pattern) - else: - url = params.get('url') - - if url.endswith('.tgz') or url.endswith('.tar.gz'): - unarch_plugin = Unarchive() - unarch_params = {'url': url, 'dest' : params.get('dest')} - return unarch_plugin.any(unarch_params) - else: - print(" Downloading {}...".format(url)) - - r = requests.get(url, stream=True) - - with open(params.get("dest"), "wb") as output: - for chunk in r.iter_content(chunk_size=1024): - if chunk: - output.write(chunk) - return True, None diff --git a/plugins/Git.py b/plugins/Git.py deleted file mode 100644 index 7bed14f..0000000 --- a/plugins/Git.py +++ /dev/null @@ -1,25 +0,0 @@ -from plugins import Plugin -from git import Repo -import os.path - - -class Git(Plugin): - - @classmethod - def get_aliases(cls): - return ["git"] - - def install(self, params): - self._validate_params(params, ['repo', 'dest'], 'git') - if os.path.exists(params.get("dest")): - return ( - False, - "Git could not clone because the specified dest path already exists: {}".format(params.get("dest"))) - else: - if params.get("branch") is None: - repo = Repo.clone_from(url=params.get("repo"), to_path=params.get("dest"), depth=1) - repo.close() - else: - repo = Repo.clone_from(url=params.get("repo"), to_path=params.get("dest"), branch=params.get("branch"), depth=1) - repo.close() - return True, None diff --git a/plugins/LineInFile.py b/plugins/LineInFile.py deleted file mode 100644 index ed0d55c..0000000 --- a/plugins/LineInFile.py +++ /dev/null @@ -1,41 +0,0 @@ -from plugins import Plugin -import os.path -import os -import katanaerrors - - -class LineInFile(Plugin): - - @classmethod - def get_aliases(cls): - return ["lineinfile"] - - def any(self, params): - self._validate_params(params, ['dest', 'line'], 'lineinfile') - state = params.get('state', 'present') - if state not in ['present', 'absent']: - raise katanaerrors.UnrecognizedParamValue('state', state, 'lineinfile', 'present, absent') - - lines = [] - if os.path.exists(params.get("dest")): - with open(params.get('dest'), 'r') as f: - lines = f.readlines() - - line = "{}\n".format(params.get('line')) - - if line in lines: - if state == 'present': - return False, "Line is already present." - else: - lines.remove(line) - with open(params.get('dest'), 'w') as f: - f.writelines(lines) - return True, None - else: - if state == "present": - lines.append(line) - with open(params.get('dest'), 'w') as f: - f.writelines(lines) - return True, None - else: - return False, "Line is already not present." diff --git a/plugins/Plugin.py b/plugins/Plugin.py deleted file mode 100644 index 63988b3..0000000 --- a/plugins/Plugin.py +++ /dev/null @@ -1,20 +0,0 @@ -import katanaerrors -import subprocess -import shlex - - -class Plugin(object): - - def _validate_params(self, params, required_params, plugin_name): - for key in required_params: - if params is None or key not in params.keys(): - raise katanaerrors.MissingRequiredParam(key, plugin_name) - - def _run_command(self, cmd, shell=None, unsafe=None, cwd=None): - if not unsafe: - cmd = shlex.split(cmd) - return subprocess.run(cmd, shell=shell, cwd=cwd) - - @classmethod - def get_aliases(cls): - return [cls.__name__] diff --git a/plugins/Remove.py b/plugins/Remove.py deleted file mode 100644 index 61743a2..0000000 --- a/plugins/Remove.py +++ /dev/null @@ -1,36 +0,0 @@ -from plugins import Plugin -import os -import os.path -import shutil - - -class Remove(Plugin): - - @classmethod - def get_aliases(cls): - return ["rm"] - - @staticmethod - def _remove(path): - if os.path.exists(path): - if os.path.isfile(path): - os.remove(path) - elif os.path.isdir(path): - shutil.rmtree(path) - return True - else: - return False - - def any(self, params): - self._validate_params(params, ['path'], 'rm') - if isinstance(params.get("path"), str): - result = self._remove(params.get("path")) - else: - result = False - for path in params.get("path"): - result = result or self._remove(path) - - if result: - return True, None - else: - return False, "Nothing to remove. Path does not exist: {}".format(params.get("path")) diff --git a/plugins/Replace.py b/plugins/Replace.py deleted file mode 100644 index b2f2930..0000000 --- a/plugins/Replace.py +++ /dev/null @@ -1,24 +0,0 @@ -from plugins import Plugin -import os.path -import os -import re -import fileinput -import katanaerrors - - -class Replace(Plugin): - - @classmethod - def get_aliases(cls): - return ["replace"] - - def any(self, params): - self._validate_params(params, ['path', 'regexp', 'replace'], 'replace') - - if not os.path.exists(params.get("path")): - raise katanaerrors.CriticalFunctionFailure("replace", message="The specified file is missing: {}".format(params.get("path"))) - else: - with fileinput.FileInput(params.get("path"), inplace=True) as file: - for line in file: - print(re.sub(params.get('regexp'), params.get('replace'), line.rstrip())) - return True, None diff --git a/plugins/ReverseProxy.py b/plugins/ReverseProxy.py deleted file mode 100644 index 10623fe..0000000 --- a/plugins/ReverseProxy.py +++ /dev/null @@ -1,92 +0,0 @@ -import os - -from plugins import Plugin - - -class ReverseProxy(Plugin): - ''' - module: reverseproxy - options: - hostname: - type: str - description: - - the hostname as exposed by the reverse proxy. The URL will be https://:8443 - proxy_pass: - type: str - description: - - The (internal) url to be proxied. This will typically be http://localhost: - ''' - - @classmethod - def get_aliases(cls): - return ["reverseproxy"] - - def install(self, params): - self._validate_params(params, ['hostname', 'proxy_pass'], 'reverseproxy') - # 1. Check if the key, csr and crt (certificate) files are already created - hostname = params.get('hostname') - base_path = '/etc/samurai.d/certs/{hostname}'.format(hostname=hostname) - files_in_place = True - for suffix in ['key', 'csr', 'crt', 'ext']: - files_in_place = files_in_place and os.path.exists('{}.{}'.format(base_path, suffix)) - - # --> If any are missing, create them. Use hostname as the name. - if not files_in_place: - self._run_command('openssl req -new -newkey rsa:4096 -nodes -keyout {hostname}.key -out {hostname}.csr -subj "/C=US/ST=Hacking/L=Springfield/O=SamuraiWTF/CN={hostname}"'.format( - hostname=hostname), cwd='/etc/samurai.d/certs/') - - ext_lines = [ - 'authorityKeyIdentifier = keyid, issuer\n', - 'basicConstraints = CA:FALSE\n', - 'keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment\n', - 'subjectAltName = @alt_names\n\n', - '[alt_names]\n', - 'DNS.1 = {hostname}\n'.format(hostname=hostname) - ] - ext_file = open('/etc/samurai.d/certs/{hostname}.ext'.format(hostname=hostname), 'w') - ext_file.writelines(ext_lines) - ext_file.close() - - self._run_command('openssl x509 -req -in {hostname}.csr -CA rootCACert.pem -CAkey rootCAKey.pem -CAcreateserial -out {hostname}.crt -days 365 -sha256 -extfile {hostname}.ext'.format(hostname=params.get('hostname')), cwd='/etc/samurai.d/certs/') - - nginx_conf_lines = [ - 'server {\n', - ' listen 80;\n', - ' server_name {hostname};\n'.format(hostname=hostname), - ' return 301 https://{hostname}:8443$request_uri;\n'.format(hostname=hostname), - '}\n', - 'server {\n', - ' listen 8443 ssl;\n', - ' server_name {hostname};\n'.format(hostname=hostname), - ' location / {\n', - ' proxy_pass {proxypass};\n'.format(proxypass=params.get('proxy_pass')), - ' }\n', - ' ssl_certificate /etc/samurai.d/certs/{hostname}.crt;\n'.format(hostname=hostname), - ' ssl_certificate_key /etc/samurai.d/certs/{hostname}.key;\n'.format(hostname=hostname), - '}\n' - ] - - nginx_conf_file = open('/etc/nginx/conf.d/{hostname}.conf'.format(hostname=hostname), 'w') - nginx_conf_file.writelines(nginx_conf_lines) - nginx_conf_file.close() - os.chmod('/etc/nginx/conf.d/{hostname}.conf'.format(hostname=hostname), 644) - - return True, None - - def remove(self, params): - self._validate_params(params, ['hostname'], 'reverseproxy') - hostname = params.get('hostname') - # ['key', 'csr', 'crt', 'ext']: - files_to_remove = [ - '/etc/samurai.d/certs/{hostname}.key', - '/etc/samurai.d/certs/{hostname}.csr', - '/etc/samurai.d/certs/{hostname}.crt', - '/etc/samurai.d/certs/{hostname}.ext', - '/etc/nginx/conf.d/{hostname}.conf' - ] - - for path in files_to_remove: - if os.path.exists(path.format(hostname=hostname)): - os.remove(path.format(hostname=hostname)) - return True, None - diff --git a/plugins/Service.py b/plugins/Service.py deleted file mode 100644 index f71fe1e..0000000 --- a/plugins/Service.py +++ /dev/null @@ -1,59 +0,0 @@ -from plugins import Plugin -import katanaerrors -import subprocess - - -class Service(Plugin): - - @classmethod - def get_aliases(cls): - return ["service"] - - def any(self, params): - self._validate_params(params, ['state', 'name'], 'service') - if params.get('state') not in ['running', 'stopped', 'restarted']: - raise katanaerrors.UnrecognizedParamValue('state', params.get('state'), 'service', 'running, stopped, restarted') - - status_code = subprocess.call(['systemctl', 'status', params.get('name'), '--no-pager']) - - if status_code == 4: - raise katanaerrors.CriticalFunctionFailure('service', 'The specified service could not be found: {}'.format(params.get('name'))) - - if params.get('state') == 'running': - if status_code == 3: - start_status_code = subprocess.call(['systemctl', 'start', params.get('name'), '--no-pager']) - if start_status_code != 0: - raise katanaerrors.CriticalFunctionFailure('service', - 'Starting the service returned status code {}'.format( - start_status_code)) - return True, None - elif status_code == 0: - return False, "The service '{}' appears to already be running.".format(params.get('name')) - else: - raise katanaerrors.CriticalFunctionFailure('service', - "The status of service '{}' returned unexpected status code: {}".format( - params.get('name'), status_code)) - elif params.get('state') == 'stopped': - if status_code == 0: - stop_status_code = subprocess.call(['systemctl', 'stop', params.get('name'), '--no-pager']) - if stop_status_code != 0: - raise katanaerrors.CriticalFunctionFailure('service', - 'Stopping the service returned status code {}'.format( - stop_status_code)) - return True, None - elif status_code == 3: - return False, "The service '{}' appears to already be stopped.".format(params.get('name')) - else: - raise katanaerrors.CriticalFunctionFailure('service', - "The status of service '{}' returned unexpected status code: {}".format( - params.get('name'), status_code)) - - elif params.get('state') == 'restarted': - restart_status_code = subprocess.call(['systemctl', 'restart', params.get('name'), '--no-pager']) - if restart_status_code != 0: - raise katanaerrors.CriticalFunctionFailure('service', - 'Retarting the service returned status code {}'.format( - restart_status_code)) - return True, None - else: - raise katanaerrors.UnrecognizedParamValue('state', params.get('state'), 'service', 'running, stopped') diff --git a/plugins/Started.py b/plugins/Started.py deleted file mode 100644 index 6bcb8b0..0000000 --- a/plugins/Started.py +++ /dev/null @@ -1,30 +0,0 @@ -from plugins import Plugin -import os.path -import docker -import subprocess - - -class Started(Plugin): - - @classmethod - def get_aliases(cls): - return ["started"] - - def service(self, value): - result = subprocess.run(['systemctl', 'status', value, '--no-pager'], text=True) - if result.returncode != 0: - print(result.stderr) - return result.returncode == 0 - # return subprocess.call(['systemctl', 'status', value]) == 0 - - def docker(self, value): - if not self.service("docker"): - return False - - client = docker.DockerClient(base_url='unix://var/run/docker.sock') - container_list = client.containers.list(filters={'name': value}, all=True) - - if len(container_list) == 0: - return False - else: - return container_list[0].status == "running" diff --git a/plugins/Unarchive.py b/plugins/Unarchive.py deleted file mode 100644 index e3bc058..0000000 --- a/plugins/Unarchive.py +++ /dev/null @@ -1,47 +0,0 @@ -from plugins import Plugin -import os.path -import requests -import re -from tarfile import TarFile -import tarfile - -class Unarchive(Plugin): - - @classmethod - def get_aliases(cls): - return ["unarchive"] - - def any(self, params): - self._validate_params(params, ['url', 'dest'], 'unarchive') - - # if os.path.exists(params.get('dest')) and not params.get('overwrite', False): - # return False, 'The specified file already exists: {}'.format(params.get('dest')) - # else: - r = requests.get(params.get('url'), stream=True) - - cd = r.headers['content-disposition'] - if cd is not None and len(cd) > 0: - temp_file_name = '/tmp/{}'.format(re.findall("filename=(.+)", cd)[0]) - else: - temp_file_name = '/tmp/tempdownload.tar.gz' - - if not os.path.exists(temp_file_name): - with open(temp_file_name, "wb") as output: - for chunk in r.iter_content(chunk_size=1024): - if chunk: - output.write(chunk) - - tar = tarfile.open(temp_file_name) - tar.extractall(path=params.get('dest')) - tar.close() - - # Clean up the downloaded file if requested - if params.get('cleanup', False): - try: - os.remove(temp_file_name) - except OSError as e: - return True, f"Extraction successful but cleanup failed: {str(e)}" - - return True, None - else: - return False, "The file '{}' already exists, so this task was skipped.".format(temp_file_name) diff --git a/plugins/Yarn.py b/plugins/Yarn.py deleted file mode 100644 index ffd661d..0000000 --- a/plugins/Yarn.py +++ /dev/null @@ -1,16 +0,0 @@ -from plugins import Command - -import subprocess -import os.path -import os - - -class Yarn(Command): - - @classmethod - def get_aliases(cls): - return ["yarn"] - - def any(self, params): - self._validate_params(params, ['path'], 'yarn') - return super(Yarn, self).any({'cmd': 'yarn', 'cwd': params.get('path')}) diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100644 index 21f8e28..0000000 --- a/plugins/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# __init__.py - -from .Plugin import Plugin -from .Copy import Copy -from .Docker import Docker -from .Git import Git -from .Remove import Remove -from .Replace import Replace -from .Command import Command -from .Unarchive import Unarchive -from .ReverseProxy import ReverseProxy diff --git a/provisioners/BaseProvisioner.py b/provisioners/BaseProvisioner.py deleted file mode 100644 index 09dc890..0000000 --- a/provisioners/BaseProvisioner.py +++ /dev/null @@ -1,56 +0,0 @@ -from os import listdir -from os.path import abspath, realpath, join, dirname, isfile - -from plugins import Plugin - - -class BaseProvisioner(object): - - plugins = {} - - def __init__(self, module_info): - self.module_info = module_info - self._load_plugins() - - def get_name(self): - return self.module_info.get("name", "(unknown)") - - def get_description(self): - return self.module_info.get("description", "(Not specified)") - - def get_category(self): - return self.module_info.get("category", "(none)") - - def get_href(self): - return self.module_info.get("href", None) - - def get_dependencies(self): - return self.module_info.get("depends-on", []) - - def has_actions(self, is_locked=False): - return [] - - @classmethod - def _load_plugins(cls): - if len(BaseProvisioner.plugins) == 0: - my_path = abspath(dirname(__file__)) - path = realpath(join(my_path, "../plugins")) - - file_list = listdir(path) - for f in file_list: - if not f.startswith('_'): - class_name = f[:-3] - mod = __import__("plugins." + class_name, fromlist=[class_name]) - klass = getattr(mod, class_name) - - plugin = klass() - if issubclass(klass, Plugin) and klass is not Plugin: - for alias in plugin.get_aliases(): - # print("alias {} points to class:{}".format(alias, klass)) - BaseProvisioner.plugins[alias] = plugin - - @classmethod - def get_plugin(cls, alias): - return BaseProvisioner.plugins.get(alias) - - diff --git a/provisioners/DefaultProvisioner.py b/provisioners/DefaultProvisioner.py deleted file mode 100644 index 4ee3f48..0000000 --- a/provisioners/DefaultProvisioner.py +++ /dev/null @@ -1,131 +0,0 @@ -from provisioners import BaseProvisioner -import katanacore -import katanaerrors - - -class DefaultProvisioner(BaseProvisioner.BaseProvisioner): - def __init__(self, module_info): - super(DefaultProvisioner, self).__init__(module_info) - possible_actions = ['stop', 'start', 'install', 'remove'] - self.action_list = [] - for a in possible_actions: - if a in module_info: - self.action_list.append(a) - - def install(self, step=False): - for dependency in self.get_dependencies(): - if katanacore.status_module(dependency) == 'not installed': - katanacore.install_module(dependency) - self._run_function("install", step) - - def remove(self, step=False): - self._run_function("remove", step) - - def start(self, step=False): - for dependency in self.get_dependencies(): - if katanacore.status_module(dependency) != 'running': - katanacore.start_module(dependency) - self._run_function("start") - - def stop(self, step=False): - self._run_function("stop") - - def has_actions(self, is_locked=False): - if is_locked: - locked_actions = {'start', 'stop'} - return list(set(self.action_list) & locked_actions) - else: - return self.action_list - - def _run_function(self, func_name, step=False): - func = self.module_info.get(func_name) - if func is None: - raise katanaerrors.NotImplemented(func_name, "DefaultProvisioner", self.get_name()) - elif len(func) == 0: - print("There are no tasks defined in '{}.{}'".format(self.get_name(), func_name)) - else: - print("Running '{}' tasks for module '{}'...".format(func_name, self.get_name())) - for task in func: - self._run_task(task, func_name) - if step: - input("Enter to continue...") - - def _run_task(self, task, func): - task_type = None - - for key in task: - if key.lower() == "name": - print("Task Name: {}".format(task.get(key))) - else: - task_type = key - print("---> Running: {} {}".format(task_type, func)) - break - - plugin = BaseProvisioner.BaseProvisioner.get_plugin(task_type) - - if hasattr(plugin, 'any') and not hasattr(plugin, func): - func = 'any' - - if hasattr(plugin, func) and callable(getattr(plugin, func)): - method_to_call = getattr(plugin, func) - result, msg = method_to_call(task.get(task_type)) - if result: - print(" + changed") - else: - print(" X no change") - if msg is not None: - print(" {}".format(msg)) - - else: - raise katanaerrors.MissingFunction(func, task_type) - - def status(self, step=False): - """Determine the current status of the module tied to this provisioner. - - :return: str representing the module status as one of ['not installed', 'installed', 'running', 'stopped'] - """ - status_checks = self.module_info.get('status', {}) - - print("checking status for {}".format(status_checks)) - - try: - has_run_check = False - if 'running' in status_checks: - has_run_check = True - print("doing running check...") - if self._do_checks(status_checks.get('running')) == 0: - return 'running' - - if 'installed' in status_checks: - print("doing installed check...") - if self._do_checks(status_checks.get('installed')) == 0: - if has_run_check: - return "stopped" - else: - return "installed" - else: - return "not installed" - else: - return "unknown" - except katanaerrors.BlockedByDependencyException: - return "blocked" - - def _do_checks(self, checks): - failed_checks = 0 - for check_type in checks.keys(): - check_pair = checks.get(check_type) - print("Check pair: {}".format(check_pair)) - for check_key in check_pair.keys(): - check_value = check_pair.get(check_key) - print("found check '{}' with value '{}'.".format(check_key, check_value)) - check_plugin = BaseProvisioner.BaseProvisioner.get_plugin(check_type) - print("check plugin: {}".format(check_plugin)) - if check_plugin is None: - raise katanaerrors.NotImplemented(check_type, 'DefaultProvisioner', self.module_info.get('name')) - elif hasattr(check_plugin, check_key) and callable(getattr(check_plugin, check_key)): - method_to_call = getattr(check_plugin, check_key) - if not method_to_call(check_value): - failed_checks = failed_checks + 1 - else: - raise katanaerrors.MissingFunction(check_type, check_key) - return failed_checks \ No newline at end of file diff --git a/provisioners/DockerProvisioner.py b/provisioners/DockerProvisioner.py deleted file mode 100644 index fbdfe6a..0000000 --- a/provisioners/DockerProvisioner.py +++ /dev/null @@ -1,114 +0,0 @@ -from provisioners import DefaultProvisioner -import katanacore -import katanaerrors - - -class DockerProvisioner(DefaultProvisioner.DefaultProvisioner): - def __init__(self, module_info): - super(DockerProvisioner, self).__init__(module_info) - self.action_list = ['stop', 'start', 'install', 'remove'] - - def get_dependencies(self): - return - - def install(self, step=False): - if katanacore.status_module('docker') == 'not installed': - katanacore.install_module('docker') - if katanacore.status_module('docker') != 'running': - katanacore.start_module('docker') - - ports = {} - for port in self.module_info.get('container', {}).get('ports', []): - ports['{}/tcp'.format(port.get('guest'))] = int(port.get('host', 80)) - - func = [ - { - 'name': 'Get latest release of {}'.format(self.module_info['name']), - 'git': { - 'repo': self.module_info.get('source').get('git-repo'), - 'dest': self.module_info.get('destination') - } - }, - { - 'name': 'Install the docker image', - 'docker': { - 'name': self.module_info.get('container').get('name'), - 'image': self.module_info.get('container').get('image', '{}'.format( - self.module_info.get('container').get('name'))), - 'path': self.module_info.get('destination'), - 'ports': ports - } - }, - { - 'name': 'Setup hosts file entry', - 'lineinfile': { - 'dest': '/etc/hosts', - 'line': '127.0.0.1 {}'.format(self.module_info.get('hosting', {}).get('domain')) - } - }, - { - 'name': 'Setup nginx reverse-proxy config', - 'copy': { - 'dest': '/etc/nginx/conf.d/{}.conf'.format(self.module_info.get('name')), - 'content': f'server {{\n' - f' listen {self.module_info.get("hosting").get("http").get("listen")};\n' - f' server_name {self.module_info.get("hosting").get("domain")};\n' - f' location / {{\n' - f' proxy_pass {self.module_info.get("hosting").get("http").get("proxy-pass")};\n' - f' }}\n' - f'}}', - 'mode': 774 - } - }, - { - 'name': 'Restart nginx', - 'service': { - 'name': 'nginx', - 'state': 'restarted' - } - } - ] - - for task in func: - self._run_task(task, "install") - - def remove(self, step=False): - if katanacore.status_module('docker') != 'running': - katanacore.start_module('docker') - func = [ - { - 'name': 'Remove docker container.', - 'docker': { - 'name': self.module_info.get('container').get('name'), - } - }, - { - 'name': 'Remove hosts file entry.', - 'lineinfile': { - 'dest': '/etc/hosts', - 'line': '127.0.0.1 {}'.format(self.module_info.get('hosting', {}).get('domain')), - 'state': 'absent' - } - }, - { - 'name': 'Remove nginx config.', - 'rm': { - 'path': '/etc/nginx/conf.d/{}.conf'.format(self.module_info.get('name')) - } - } - ] - - for task in func: - self._run_task(task, "remove") - - def start(self, step=False): - if katanacore.status_module('docker') != 'running': - katanacore.start_module('docker') - self._run_task({'docker': {'name': self.module_info.get('container').get('name')}}, 'start') - - def stop(self, step=False): - if katanacore.status_module('docker') != 'running': - katanacore.start_module('docker') - self._run_task({'docker': {'name': self.module_info.get('container').get('name')}}, 'stop') - -# docker build -t plugin-lab . && docker run -p 127.0.0.1:8081:3000 plugin-lab diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 769062b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -cherrypy>=18.6.0 -docker>=4.2.0 -PyYAML>=5.3.1 -requests>=2.23.0 -GitPython>=3.1.2 diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c1513e7 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,296 @@ +#!/usr/bin/env bun +import { Command } from "commander"; +import { registerCertCommands } from "./commands/cert.ts"; +import { registerCleanupCommand } from "./commands/cleanup.ts"; +import { registerDnsCommands } from "./commands/dns.ts"; +import { registerDoctorCommand } from "./commands/doctor.ts"; +import { installCommand } from "./commands/install.ts"; +import { logsCommand } from "./commands/logs.ts"; +import { registerProxyCommands } from "./commands/proxy.ts"; +import { removeCommand } from "./commands/remove.ts"; +import { registerSetupCommands } from "./commands/setup.ts"; +import { startCommand } from "./commands/start.ts"; +import { stopCommand } from "./commands/stop.ts"; +import { getComposeManager } from "./core/compose-manager.ts"; +import { getConfigManager, initConfigManager } from "./core/config-manager.ts"; +import { getModuleLoader } from "./core/module-loader.ts"; +import { getStateManager } from "./core/state-manager.ts"; +import { KatanaError } from "./types/errors.ts"; +import { logger } from "./utils/logger.ts"; + +const program = new Command(); + +program + .name("katana") + .description("OWASP SamuraiWTF lab management solution") + .version("2.0.0") + .option("-c, --config ", "Path to config file") + .hook("preAction", (thisCommand) => { + // Initialize ConfigManager with custom path if provided + const opts = thisCommand.opts(); + if (opts.config) { + initConfigManager(opts.config); + } + }); + +// Status command - shows system status +program + .command("status") + .description("Show system status") + .action(async () => { + try { + const configManager = getConfigManager(); + const stateManager = getStateManager(); + + const config = await configManager.get(); + const state = await stateManager.get(); + + console.log("Katana System Status"); + console.log("====================\n"); + + console.log(`Locked: ${state.locked ? "Yes" : "No"}`); + console.log(`Install Type: ${config.install_type}`); + const domain = config.install_type === "remote" ? config.base_domain : config.local_domain; + console.log(`Domain: ${domain}`); + + // Count running targets + let runningCount = 0; + if (state.targets.length > 0) { + const composeManager = await getComposeManager(); + for (const target of state.targets) { + const status = await composeManager.status(target.name); + if (status.all_running) runningCount++; + } + } + + console.log(`Targets: ${state.targets.length} installed, ${runningCount} running`); + console.log(`Tools: ${state.tools.length} installed`); + + if (state.targets.length > 0) { + console.log("\nInstalled Targets:"); + const composeManager = await getComposeManager(); + for (const target of state.targets) { + const status = await composeManager.status(target.name); + const statusIcon = status.all_running ? "\u2713" : "\u2717"; + const statusText = status.all_running ? "running" : "stopped"; + console.log(` ${statusIcon} ${target.name.padEnd(15)} (${statusText})`); + } + } + + if (state.tools.length > 0) { + console.log("\nInstalled Tools:"); + for (const tool of state.tools) { + console.log(` - ${tool.name}${tool.version ? ` v${tool.version}` : ""}`); + } + } + + console.log(`\nConfig: ${configManager.getPath()}`); + console.log(`State: ${stateManager.getPath()}`); + } catch (error) { + handleError(error); + } + }); + +// Lock command +program + .command("lock") + .description("Lock the system to prevent modifications") + .action(async () => { + try { + const stateManager = getStateManager(); + await stateManager.setLocked(true); + logger.success("System locked"); + } catch (error) { + handleError(error); + } + }); + +// Unlock command +program + .command("unlock") + .description("Unlock the system to allow modifications") + .action(async () => { + try { + const stateManager = getStateManager(); + await stateManager.setLocked(false); + logger.success("System unlocked"); + } catch (error) { + handleError(error); + } + }); + +// List command - shows available modules +program + .command("list") + .description("List available modules") + .argument("[category]", "Filter by category: targets or tools") + .option("--installed", "Show only installed modules") + .action(async (category: string | undefined, options: { installed?: boolean }) => { + try { + const moduleLoader = await getModuleLoader(); + const stateManager = getStateManager(); + const state = await stateManager.get(); + + // Get installed module names for marking + const installedTargets = new Set(state.targets.map((t) => t.name)); + const installedTools = new Set(state.tools.map((t) => t.name)); + + // Determine which categories to show + const showTargets = !category || category === "targets"; + const showTools = !category || category === "tools"; + + // Validate category argument + if (category && category !== "targets" && category !== "tools") { + logger.error(`Invalid category: ${category}. Use 'targets' or 'tools'.`); + process.exit(1); + } + + // Load and display targets + if (showTargets) { + const targets = await moduleLoader.loadByCategory("targets"); + const filteredTargets = options.installed + ? targets.filter((m) => installedTargets.has(m.name)) + : targets; + + console.log("Available Targets:"); + if (filteredTargets.length === 0) { + console.log(" (none)"); + } else { + for (const target of filteredTargets) { + const installed = installedTargets.has(target.name) ? " [installed]" : ""; + console.log(` ${target.name.padEnd(15)} - ${target.description}${installed}`); + } + } + } + + // Load and display tools + if (showTools) { + if (showTargets) console.log(); // Add spacing between sections + + const tools = await moduleLoader.loadByCategory("tools"); + const filteredTools = options.installed + ? tools.filter((m) => installedTools.has(m.name)) + : tools; + + console.log("Available Tools:"); + if (filteredTools.length === 0) { + console.log(" (none)"); + } else { + for (const tool of filteredTools) { + const installed = installedTools.has(tool.name) ? " [installed]" : ""; + console.log(` ${tool.name.padEnd(15)} - ${tool.description}${installed}`); + } + } + } + } catch (error) { + handleError(error); + } + }); + +// Install command +program + .command("install ") + .description("Install a target or tool") + .option("--skip-dns", "Skip DNS update reminder") + .action(async (name: string, options: { skipDns?: boolean }) => { + try { + await installCommand(name, options); + } catch (error) { + handleError(error); + } + }); + +// Remove command +program + .command("remove ") + .description("Remove an installed target or tool") + .action(async (name: string) => { + try { + await removeCommand(name); + } catch (error) { + handleError(error); + } + }); + +// Start command +program + .command("start ") + .description("Start a stopped target") + .action(async (name: string) => { + try { + await startCommand(name); + } catch (error) { + handleError(error); + } + }); + +// Stop command +program + .command("stop ") + .description("Stop a running target") + .action(async (name: string) => { + try { + await stopCommand(name); + } catch (error) { + handleError(error); + } + }); + +// Logs command +program + .command("logs ") + .description("View logs for a target") + .option("-f, --follow", "Follow log output") + .option("-t, --tail ", "Number of lines to show", "100") + .action(async (name: string, options: { follow?: boolean; tail?: string }) => { + try { + await logsCommand(name, { + follow: options.follow, + tail: options.tail ? Number.parseInt(options.tail, 10) : 100, + }); + } catch (error) { + handleError(error); + } + }); + +// Certificate commands +registerCertCommands(program); + +// DNS commands +registerDnsCommands(program); + +// Proxy commands +registerProxyCommands(program); + +// Setup commands +registerSetupCommands(program); + +// Doctor command +registerDoctorCommand(program); + +// Cleanup command +registerCleanupCommand(program); + +/** + * Handle errors consistently + */ +export function handleError(error: unknown): void { + if (error instanceof KatanaError) { + logger.error(error.message); + if (error.help) { + console.log(`Help: ${error.help()}`); + } + process.exit(1); + } + + if (error instanceof Error) { + logger.error(error.message); + process.exit(1); + } + + logger.error("An unknown error occurred"); + process.exit(1); +} + +// Parse and execute +program.parse(); diff --git a/src/commands/cert.ts b/src/commands/cert.ts new file mode 100644 index 0000000..bfb560c --- /dev/null +++ b/src/commands/cert.ts @@ -0,0 +1,156 @@ +import type { Command } from "commander"; +import { getCertManager } from "../core/cert-manager.ts"; +import { KatanaError } from "../types/errors.ts"; +import { logger } from "../utils/logger.ts"; +import { resolvePath } from "../utils/paths.ts"; + +/** + * Register certificate management commands + */ +export function registerCertCommands(program: Command): void { + const cert = program.command("cert").description("Certificate management commands"); + + // katana cert init + cert + .command("init") + .description("Initialize CA and generate server certificates") + .action(async () => { + try { + const certManager = getCertManager(); + + const isInit = await certManager.isInitialized(); + if (isInit) { + logger.info("CA already exists, regenerating server certificate..."); + } else { + logger.info("Initializing Certificate Authority..."); + } + + await certManager.initCA(); + + const days = await certManager.daysUntilExpiration(); + logger.success("Certificates initialized successfully"); + logger.info(`Server certificate expires in ${days} days`); + logger.info(""); + logger.info("To trust the CA in your browser:"); + logger.info(" katana cert export"); + logger.info(" Then import ca.crt into your browser's certificate store"); + } catch (error) { + handleError(error); + } + }); + + // katana cert renew + cert + .command("renew") + .description("Renew server certificate (keeps existing CA)") + .action(async () => { + try { + const certManager = getCertManager(); + + if (!(await certManager.isInitialized())) { + logger.error("CA not initialized. Run 'katana cert init' first."); + process.exit(1); + } + + logger.info("Renewing server certificate..."); + await certManager.renewCert(); + + const days = await certManager.daysUntilExpiration(); + logger.success("Certificate renewed successfully"); + logger.info(`New certificate expires in ${days} days`); + } catch (error) { + handleError(error); + } + }); + + // katana cert export [path] + cert + .command("export") + .argument("[path]", "Destination path", "./ca.crt") + .description("Export CA certificate for browser import") + .action(async (path: string) => { + try { + const certManager = getCertManager(); + + if (!(await certManager.isInitialized())) { + logger.error("CA not initialized. Run 'katana cert init' first."); + process.exit(1); + } + + const destPath = resolvePath(path); + await certManager.exportCA(destPath); + + logger.success(`CA certificate exported to: ${destPath}`); + logger.info(""); + logger.info("Import this certificate into your browser:"); + logger.info( + " Firefox: Preferences → Privacy & Security → Certificates → View Certificates → Import", + ); + logger.info( + " Chrome: Settings → Privacy and security → Security → Manage certificates → Authorities → Import", + ); + } catch (error) { + handleError(error); + } + }); + + // katana cert status + cert + .command("status") + .description("Show certificate status") + .action(async () => { + try { + const certManager = getCertManager(); + + console.log("Certificate Status"); + console.log("==================\n"); + + const isInit = await certManager.isInitialized(); + if (!isInit) { + logger.error("CA not initialized"); + logger.info("Run: katana cert init"); + return; + } + + logger.success("CA initialized"); + + const valid = await certManager.validateCerts(); + const days = await certManager.daysUntilExpiration(); + + if (valid) { + logger.success(`Server certificate valid (expires in ${days} days)`); + if (days < 30) { + logger.warn("Certificate expires soon! Run: katana cert renew"); + } + } else { + logger.error("Server certificate invalid or expired"); + logger.info("Run: katana cert renew"); + } + + console.log(`\nCertificates location: ${certManager.getPath()}`); + } catch (error) { + handleError(error); + } + }); +} + +/** + * Handle errors consistently + */ +function handleError(error: unknown): void { + if (error instanceof KatanaError) { + logger.error(error.message); + if (error.help) { + console.log(`Help: ${error.help()}`); + } + process.exit(1); + } + + if (error instanceof Error) { + logger.error(error.message); + process.exit(1); + } + + logger.error("An unknown error occurred"); + process.exit(1); +} diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts new file mode 100644 index 0000000..cff8d10 --- /dev/null +++ b/src/commands/cleanup.ts @@ -0,0 +1,185 @@ +import type { Command } from "commander"; +import { getConfigManager } from "../core/config-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { getDnsManager } from "../platform/linux/dns-manager.ts"; +import { KatanaError } from "../types/errors.ts"; +import { logger } from "../utils/logger.ts"; + +interface CleanupResult { + orphanedContainers: string[]; + dnsFixed: boolean; + dnsMessage: string; + pruneResult?: string; +} + +/** + * Run cleanup operations + */ +async function runCleanup(options: { + prune?: boolean; + dryRun?: boolean; +}): Promise { + const dockerClient = getDockerClient(); + const stateManager = getStateManager(); + const configManager = getConfigManager(); + const dnsManager = getDnsManager(); + + const result: CleanupResult = { + orphanedContainers: [], + dnsFixed: false, + dnsMessage: "", + }; + + // 1. Find and remove orphaned containers + const state = await stateManager.get(); + const knownProjects = new Set(state.targets.map((t) => `katana-${t.name}`)); + + const katanaContainers = await dockerClient.listKatanaContainers(); + + for (const container of katanaContainers) { + const project = container.labels["com.docker.compose.project"]; + if (project && !knownProjects.has(project)) { + // This is an orphaned container + if (!options.dryRun) { + try { + await dockerClient.removeContainer(container.name, { force: true, volumes: true }); + result.orphanedContainers.push(container.name); + } catch (error) { + logger.warn(`Failed to remove ${container.name}: ${error}`); + } + } else { + result.orphanedContainers.push(container.name); + } + } + } + + // 2. Check DNS entries + const config = await configManager.get(); + + // Build expected hostnames + const expectedHostnames: string[] = []; + + // Add dashboard hostname + const domain = config.install_type === "remote" ? config.base_domain : config.local_domain; + expectedHostnames.push(`${config.dashboard_hostname}.${domain}`); + + // Add target hostnames + for (const target of state.targets) { + for (const route of target.routes) { + expectedHostnames.push(route.hostname); + } + } + + // Check current managed entries + const managedEntries = await dnsManager.listManaged(); + const currentHostnames = new Set(managedEntries.map((e) => e.hostname)); + const expectedSet = new Set(expectedHostnames); + + const missing = expectedHostnames.filter((h) => !currentHostnames.has(h)); + const extra = [...currentHostnames].filter((h) => !expectedSet.has(h)); + const needsSync = missing.length > 0 || extra.length > 0; + + if (needsSync) { + // Check if running as root + const isRoot = process.getuid?.() === 0; + + if (isRoot && !options.dryRun) { + const syncResult = await dnsManager.sync(expectedHostnames); + result.dnsFixed = true; + const changes = syncResult.added.length + syncResult.removed.length; + result.dnsMessage = `Fixed ${changes} DNS entries`; + } else if (isRoot && options.dryRun) { + result.dnsMessage = `Would fix ${missing.length + extra.length} DNS entries`; + } else { + result.dnsMessage = `DNS out of sync (${missing.length} missing, ${extra.length} extra) - run: sudo katana dns sync`; + } + } else { + result.dnsMessage = "DNS entries in sync"; + result.dnsFixed = true; + } + + // 3. Prune Docker images (optional) + if (options.prune) { + if (!options.dryRun) { + try { + const spaceReclaimed = await dockerClient.pruneImages(); + result.pruneResult = `Reclaimed ${spaceReclaimed}`; + } catch (error) { + result.pruneResult = `Prune failed: ${error}`; + } + } else { + result.pruneResult = "Would prune unused images"; + } + } + + return result; +} + +/** + * Register the cleanup command + */ +export function registerCleanupCommand(program: Command): void { + program + .command("cleanup") + .description("Remove orphaned resources and fix inconsistencies") + .option("--prune", "Also prune unused Docker images") + .option("--dry-run", "Show what would be done without making changes") + .action(async (options: { prune?: boolean; dryRun?: boolean }) => { + try { + console.log("Katana Cleanup"); + console.log("==============\n"); + + if (options.dryRun) { + console.log("[DRY RUN - no changes will be made]\n"); + } + + const result = await runCleanup(options); + + // Orphaned containers + console.log("Orphaned Containers:"); + if (result.orphanedContainers.length === 0) { + console.log(" None found"); + } else { + const action = options.dryRun ? "Would remove" : "Removed"; + for (const name of result.orphanedContainers) { + console.log(` → ${action}: ${name}`); + } + } + + // DNS + console.log("\nDNS Entries:"); + if (result.dnsFixed) { + logger.success(` ${result.dnsMessage}`); + } else { + logger.warn(` ${result.dnsMessage}`); + } + + // Prune + console.log("\nDocker Prune:"); + if (result.pruneResult) { + console.log(` → ${result.pruneResult}`); + } else { + console.log(" → Skipped (use --prune to enable)"); + } + + console.log("\nCleanup complete."); + } catch (error) { + if (error instanceof KatanaError) { + logger.error(error.message); + if (error.help) { + console.log(`Help: ${error.help()}`); + } + process.exit(1); + } + + if (error instanceof Error) { + logger.error(error.message); + process.exit(1); + } + + logger.error("An unknown error occurred"); + process.exit(1); + } + }); +} diff --git a/src/commands/dns.ts b/src/commands/dns.ts new file mode 100644 index 0000000..b0257a9 --- /dev/null +++ b/src/commands/dns.ts @@ -0,0 +1,156 @@ +import type { Command } from "commander"; +import { getConfigManager } from "../core/config-manager.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { getDnsManager } from "../platform/index.ts"; +import { getDashboardHostname, getTargetHostname } from "../types/config.ts"; +import { KatanaError } from "../types/errors.ts"; +import type { TargetModule } from "../types/module.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Register DNS management commands + */ +export function registerDnsCommands(program: Command): void { + const dns = program.command("dns").description("DNS management commands"); + + // katana dns sync + dns + .command("sync") + .description("Synchronize /etc/hosts with installed targets (requires sudo)") + .option("--all", "Sync all available targets, not just installed ones") + .action(async (options: { all?: boolean }) => { + try { + const configManager = getConfigManager(); + const stateManager = getStateManager(); + const dnsManager = getDnsManager(); + + const config = await configManager.get(); + const state = await stateManager.get(); + + // Warn if remote install + if (config.install_type === "remote") { + logger.warn("Remote installs use wildcard DNS - /etc/hosts sync is not needed"); + logger.info("Configure wildcard DNS (e.g., *.domain → server IP) instead"); + return; + } + + // Build list of hostnames + const hostnames: string[] = []; + + // Add dashboard hostname + const dashboardHost = getDashboardHostname(config); + hostnames.push(dashboardHost); + + if (options.all) { + // Add hostnames from ALL available targets + const moduleLoader = await getModuleLoader(); + const targets = await moduleLoader.loadByCategory("targets"); + + for (const target of targets) { + const targetModule = target as TargetModule; + for (const proxy of targetModule.proxy) { + const hostname = getTargetHostname(config, proxy.hostname); + hostnames.push(hostname); + } + } + + logger.info("Syncing DNS entries for all available targets..."); + } else { + // Add hostnames from installed targets only + for (const target of state.targets) { + for (const route of target.routes) { + hostnames.push(route.hostname); + } + } + + if (hostnames.length <= 1) { + logger.info("No targets installed - only syncing dashboard hostname"); + logger.info("Use --all to sync all available targets"); + } else { + logger.info("Syncing DNS entries for installed targets..."); + } + } + + const result = await dnsManager.sync(hostnames); + + // Report results + console.log("\nDNS Sync Complete"); + console.log("================="); + + if (result.added.length > 0) { + console.log(`Added: ${result.added.join(", ")}`); + } + if (result.removed.length > 0) { + console.log(`Removed: ${result.removed.join(", ")}`); + } + if (result.unchanged.length > 0) { + console.log(`Unchanged: ${result.unchanged.length} entries`); + } + + if (result.added.length === 0 && result.removed.length === 0) { + logger.success("Already in sync - no changes needed"); + } else { + logger.success("DNS entries updated"); + } + } catch (error) { + handleError(error); + } + }); + + // katana dns list + dns + .command("list") + .description("List DNS entries in /etc/hosts") + .option("--all", "Show all entries (not just Katana-managed)") + .action(async (options: { all?: boolean }) => { + try { + const dnsManager = getDnsManager(); + + const entries = options.all ? await dnsManager.read() : await dnsManager.listManaged(); + + console.log("DNS Entries (/etc/hosts)"); + console.log("========================\n"); + + if (entries.length === 0) { + if (options.all) { + logger.info("No entries found"); + } else { + logger.info("No Katana-managed entries found"); + logger.info("Run: sudo katana dns sync"); + } + return; + } + + for (const entry of entries) { + const managedIcon = entry.managed ? "\u2713 managed" : ""; + console.log(`${entry.ip.padEnd(16)} ${entry.hostname.padEnd(30)} ${managedIcon}`); + } + + console.log(`\nHosts file: ${dnsManager.getPath()}`); + } catch (error) { + handleError(error); + } + }); +} + +/** + * Handle errors consistently + */ +function handleError(error: unknown): void { + if (error instanceof KatanaError) { + logger.error(error.message); + if (error.help) { + console.log(`Help: ${error.help()}`); + } + process.exit(1); + } + + if (error instanceof Error) { + logger.error(error.message); + process.exit(1); + } + + logger.error("An unknown error occurred"); + process.exit(1); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..938a47b --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,389 @@ +import type { Command } from "commander"; +import { getCertManager } from "../core/cert-manager.ts"; +import { getConfigManager } from "../core/config-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { getDnsManager } from "../platform/linux/dns-manager.ts"; +import { getDashboardHostname, getTargetHostname } from "../types/config.ts"; +import { DockerNotRunningError, DockerPermissionError } from "../types/errors.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Result of a single health check + */ +interface CheckResult { + name: string; + passed: boolean; + message: string; + help?: string; +} + +/** + * Run all health checks and return results + */ +async function runHealthChecks(): Promise { + const results: CheckResult[] = []; + const dockerClient = getDockerClient(); + const certManager = getCertManager(); + const stateManager = getStateManager(); + const configManager = getConfigManager(); + const dnsManager = getDnsManager(); + const config = await configManager.get(); + + // 1. Docker daemon running + const dockerRunning = await dockerClient.ping(); + results.push({ + name: "Docker daemon", + passed: dockerRunning, + message: dockerRunning ? "Docker daemon running" : "Docker daemon not running", + help: dockerRunning ? undefined : "sudo systemctl start docker", + }); + + // Skip remaining Docker checks if daemon not running + if (!dockerRunning) { + results.push({ + name: "Docker permissions", + passed: false, + message: "Skipped (Docker not running)", + }); + results.push({ + name: "Docker network", + passed: false, + message: "Skipped (Docker not running)", + }); + } else { + // 2. Docker permissions + let hasPermissions = false; + let permissionError = ""; + try { + await dockerClient.checkPermissions(); + hasPermissions = true; + } catch (error) { + if (error instanceof DockerNotRunningError) { + permissionError = "Docker not running"; + } else if (error instanceof DockerPermissionError) { + permissionError = "Permission denied"; + } else { + permissionError = String(error); + } + } + results.push({ + name: "Docker permissions", + passed: hasPermissions, + message: hasPermissions ? "User has Docker permissions" : permissionError, + help: hasPermissions ? undefined : "sudo usermod -aG docker $USER && newgrp docker", + }); + + // 3. Docker network exists + const networkExists = await dockerClient.networkExists(config.docker_network); + results.push({ + name: "Docker network", + passed: networkExists, + message: networkExists + ? `Docker network '${config.docker_network}' exists` + : `Docker network '${config.docker_network}' missing`, + help: networkExists ? undefined : "Network will be created when you install a target", + }); + } + + // 4. OpenSSL available + const opensslAvailable = await checkOpenSSL(); + results.push({ + name: "OpenSSL", + passed: opensslAvailable, + message: opensslAvailable ? "OpenSSL available" : "OpenSSL not found", + help: opensslAvailable ? undefined : "sudo apt install openssl", + }); + + // 5. CA initialized + const caInitialized = await certManager.isInitialized(); + results.push({ + name: "Certificates initialized", + passed: caInitialized, + message: caInitialized ? "Certificates initialized" : "Certificates not initialized", + help: caInitialized ? undefined : "katana cert init", + }); + + // 6 & 7. Certificates valid and expiration + if (caInitialized) { + const certsValid = await certManager.validateCerts(); + const daysUntilExpiry = await certManager.daysUntilExpiration(); + + if (certsValid) { + // Check for expiration warning + if (daysUntilExpiry <= 30) { + results.push({ + name: "Certificates valid", + passed: true, + message: `Certificates valid (expires in ${daysUntilExpiry} days - renew soon!)`, + help: "katana cert renew", + }); + } else { + results.push({ + name: "Certificates valid", + passed: true, + message: `Certificates valid (expires in ${daysUntilExpiry} days)`, + }); + } + } else { + results.push({ + name: "Certificates valid", + passed: false, + message: daysUntilExpiry < 0 ? "Certificates expired" : "Certificates invalid", + help: "katana cert renew", + }); + } + } else { + results.push({ + name: "Certificates valid", + passed: false, + message: "Skipped (certificates not initialized)", + }); + } + + // 8. Port 443 capability + const portCapability = await checkPortCapability(); + results.push({ + name: "Port 443 capability", + passed: portCapability, + message: portCapability ? "Port 443 bindable" : "Missing port binding capability", + help: portCapability ? undefined : "sudo katana setup-proxy", + }); + + // 9. DNS sync check + try { + const state = await stateManager.get(); + const moduleLoader = await getModuleLoader(); + const expectedHostnames = new Set(); + + // Collect all expected hostnames from installed targets + // IMPORTANT: Recompute hostnames using current config, not stale state routes + // This handles config changes like switching from .test to .localhost + for (const target of state.targets) { + const module = await moduleLoader.findModule(target.name); + if (module && module.category === "targets") { + for (const proxy of module.proxy) { + const hostname = getTargetHostname(config, proxy.hostname); + expectedHostnames.add(hostname); + } + } + } + + // Add dashboard hostname (using the same utility function as dns sync) + const dashboardHostname = getDashboardHostname(config); + expectedHostnames.add(dashboardHostname); + + // Get current managed entries + const managedEntries = await dnsManager.listManaged(); + const currentHostnames = new Set(managedEntries.map((e) => e.hostname)); + + // Compare sets + const missing = [...expectedHostnames].filter((h) => !currentHostnames.has(h)); + const extra = [...currentHostnames].filter((h) => !expectedHostnames.has(h)); + + // Only fail if expected entries are missing + // Extra entries (e.g., from using --all flag) are harmless + const allPresent = missing.length === 0; + const totalExpected = expectedHostnames.size; + const totalPresent = totalExpected - missing.length; + + let message: string; + if (allPresent && extra.length === 0) { + message = `DNS entries in sync (${totalPresent}/${totalExpected})`; + } else if (allPresent && extra.length > 0) { + message = `DNS entries present (${totalPresent}/${totalExpected}, ${extra.length} extra)`; + } else { + message = `DNS entries missing (${totalPresent}/${totalExpected})`; + } + + results.push({ + name: "DNS entries", + passed: allPresent, + message, + help: allPresent ? undefined : "sudo katana dns sync", + }); + } catch { + results.push({ + name: "DNS entries", + passed: false, + message: "Could not check DNS entries", + help: "Check /etc/hosts permissions", + }); + } + + // 10. State file valid + try { + await stateManager.get(); + results.push({ + name: "State file", + passed: true, + message: "State file valid", + }); + } catch (error) { + results.push({ + name: "State file", + passed: false, + message: `State file error: ${error instanceof Error ? error.message : String(error)}`, + }); + } + + return results; +} + +/** + * Check if OpenSSL is available + */ +async function checkOpenSSL(): Promise { + try { + const proc = Bun.spawn(["which", "openssl"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + +/** + * Check if binary has cap_net_bind_service capability + */ +async function checkPortCapability(): Promise { + try { + // Check if we're running as root (always allowed) + if (process.getuid?.() === 0) { + return true; + } + + // Try to resolve the actual binary path + // 1. Try 'which katana' if it's in PATH + // 2. Try reading /proc/self/exe (Linux) + // 3. Fall back to Bun.main + let katanaPath: string | undefined; + + // Try 'which' first + try { + const whichProc = Bun.spawn(["which", "katana"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await whichProc.exited; + const output = await new Response(whichProc.stdout).text(); + if (exitCode === 0 && output.trim()) { + katanaPath = output.trim(); + } + } catch { + // which command failed + } + + // If which didn't work, use process.argv[1] + // When running compiled binary with full path, this gives us the actual path + if (!katanaPath && process.argv[1]) { + // Resolve to absolute path if needed + try { + const resolveProc = Bun.spawn(["realpath", process.argv[1]], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await resolveProc.exited; + const output = await new Response(resolveProc.stdout).text(); + if (exitCode === 0 && output.trim()) { + katanaPath = output.trim(); + } + } catch { + // If realpath fails, use argv[1] as-is + katanaPath = process.argv[1]; + } + } + + // Last resort: try Bun.main + if (!katanaPath) { + katanaPath = Bun.main; + } + + // If we still don't have a path, try the port binding test + if (!katanaPath) { + return await tryBindPort443(); + } + + // Check getcap on the executable + const proc = Bun.spawn(["getcap", katanaPath], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0 || !stdout.trim()) { + // getcap failed or returned empty output (path doesn't exist), try alternative check + return await tryBindPort443(); + } + + // Check if cap_net_bind_service is in the output + return stdout.includes("cap_net_bind_service"); + } catch { + // Fallback: try to actually bind to port 443 + return await tryBindPort443(); + } +} + +/** + * Try to bind to port 443 to check capability + */ +async function tryBindPort443(): Promise { + try { + const server = Bun.serve({ + port: 443, + fetch() { + return new Response("test"); + }, + }); + server.stop(); + return true; + } catch { + return false; + } +} + +/** + * Register the doctor command + */ +export function registerDoctorCommand(program: Command): void { + program + .command("doctor") + .description("Run health checks on the system") + .option("--json", "Output results as JSON") + .action(async (options: { json?: boolean }) => { + const results = await runHealthChecks(); + + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log("Katana Health Check"); + console.log("===================\n"); + + for (const result of results) { + if (result.passed) { + logger.success(result.message); + } else { + logger.error(result.message); + if (result.help) { + console.log(` → Fix: ${result.help}`); + } + } + } + + const passed = results.filter((r) => r.passed).length; + const total = results.length; + + console.log(`\nHealth: ${passed}/${total} checks passed`); + + if (passed < total) { + process.exit(1); + } + } + }); +} diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 0000000..d80b6f9 --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,157 @@ +import { getComposeManager } from "../core/compose-manager.ts"; +import { getConfigManager } from "../core/config-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { getToolExecutor } from "../core/tool-executor.ts"; +import { type Config, getTargetHostname } from "../types/config.ts"; +import { AlreadyExistsError, NotFoundError, SystemLockedError } from "../types/errors.ts"; +import { + type TargetModule, + type ToolModule, + isTargetModule, + isToolModule, +} from "../types/module.ts"; +import type { ProxyRoute, TargetState, ToolState } from "../types/state.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Build environment variables with full hostnames from configured domain + * Transforms variables ending in _HOST to use getTargetHostname() + */ +function buildEnvWithDomain( + moduleEnv: Record | undefined, + config: Config, +): Record { + if (!moduleEnv) return {}; + + const result: Record = {}; + for (const [key, value] of Object.entries(moduleEnv)) { + // Transform hostname variables to use configured domain + if (key.endsWith("_HOST")) { + result[key] = getTargetHostname(config, value); + } else { + result[key] = value; + } + } + return result; +} + +/** + * Install a target or tool + */ +export async function installCommand(name: string, options: { skipDns?: boolean }): Promise { + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + const configManager = getConfigManager(); + + // Check Docker connectivity first + const docker = getDockerClient(); + await docker.checkPermissions(); + + // Check if system is locked + if (await stateManager.isLocked()) { + throw new SystemLockedError(); + } + + // Load module + const module = await moduleLoader.findModule(name); + if (!module) { + throw new NotFoundError("Module", name); + } + + // Check if already installed + const existingTarget = await stateManager.findTarget(name); + const existingTool = await stateManager.findTool(name); + if (existingTarget || existingTool) { + throw new AlreadyExistsError(existingTarget ? "Target" : "Tool", name); + } + + // Handle based on category + if (isTargetModule(module)) { + await installTarget(module, stateManager, configManager); + } else if (isToolModule(module)) { + await installTool(module, stateManager); + } else { + logger.error("Unknown module category"); + process.exit(1); + } + + // Print DNS reminder for targets only (unless skipped) + if (!options.skipDns && isTargetModule(module)) { + logger.info(""); + logger.warn("Run 'sudo katana dns sync' to update DNS entries"); + } +} + +/** + * Install a target module + */ +async function installTarget( + module: TargetModule, + stateManager: ReturnType, + configManager: ReturnType, +): Promise { + const composeManager = await getComposeManager(); + const config = await configManager.get(); + + logger.info(`Installing target: ${module.name}`); + + // Build environment variables with configured domain + const env = buildEnvWithDomain(module.env, config); + + // Run docker compose up with transformed environment + await composeManager.up(module, env); + + // Build routes for state + const routes: ProxyRoute[] = module.proxy.map((p) => ({ + hostname: getTargetHostname(config, p.hostname), + service: p.service, + port: p.port, + })); + + // Add to state + const targetState: TargetState = { + name: module.name, + installed_at: new Date().toISOString(), + compose_project: composeManager.getProjectName(module.name), + routes, + }; + + await stateManager.addTarget(targetState); + + logger.success(`Target ${module.name} installed successfully`); + const primaryRoute = routes[0]; + if (primaryRoute) { + logger.info(`Access at: https://${primaryRoute.hostname}/`); + } +} + +/** + * Install a tool module + */ +async function installTool( + module: ToolModule, + stateManager: ReturnType, +): Promise { + const toolExecutor = getToolExecutor(); + + logger.info(`Installing tool: ${module.name}`); + + // Execute install script + const result = await toolExecutor.executeInstall(module); + + // Add to state + const toolState: ToolState = { + name: module.name, + installed_at: new Date().toISOString(), + version: result.version, + }; + + await stateManager.addTool(toolState); + + logger.success(`Tool ${module.name} installed successfully`); + if (result.version) { + logger.info(`Version: ${result.version}`); + } +} diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..166346c --- /dev/null +++ b/src/commands/logs.ts @@ -0,0 +1,41 @@ +import { getComposeManager } from "../core/compose-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { NotFoundError } from "../types/errors.ts"; +import { isTargetModule } from "../types/module.ts"; + +/** + * View logs for a target + */ +export async function logsCommand( + name: string, + options: { follow?: boolean; tail?: number }, +): Promise { + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + + // Check Docker connectivity first + const docker = getDockerClient(); + await docker.checkPermissions(); + + // Check if installed + const target = await stateManager.findTarget(name); + if (!target) { + throw new NotFoundError("Installed target", name); + } + + // Load module to get path + const module = await moduleLoader.findModule(name); + if (!module || !isTargetModule(module)) { + throw new NotFoundError("Module", name); + } + + if (!module.path) { + throw new NotFoundError("Module path", name); + } + + // Stream logs + const composeManager = await getComposeManager(); + await composeManager.logs(name, module.path, options); +} diff --git a/src/commands/proxy.ts b/src/commands/proxy.ts new file mode 100644 index 0000000..1658bc6 --- /dev/null +++ b/src/commands/proxy.ts @@ -0,0 +1,59 @@ +import type { Command } from "commander"; +import { handleError } from "../cli.ts"; +import { getConfigManager } from "../core/config-manager.ts"; +import { getProxyRouter } from "../core/proxy-router.ts"; +import { startProxyServer } from "../server.ts"; +import { getBindAddress } from "../types/config.ts"; + +/** + * Register proxy commands + */ +export function registerProxyCommands(program: Command): void { + const proxy = program.command("proxy").description("Manage the reverse proxy server"); + + proxy + .command("start") + .description("Start the reverse proxy server (foreground)") + .action(async () => { + try { + await startProxyServer(); + } catch (error) { + handleError(error); + } + }); + + proxy + .command("status") + .description("Show proxy configuration and routes") + .action(async () => { + try { + const configManager = getConfigManager(); + const config = await configManager.get(); + const router = await getProxyRouter(); + const bindAddress = getBindAddress(config); + + console.log("Proxy Configuration"); + console.log("==================="); + console.log(`Bind Address: ${bindAddress}`); + console.log(`HTTPS Port: ${config.proxy.https_port}`); + console.log(`HTTP Port: ${config.proxy.http_port}`); + console.log(`Dashboard: https://${router.getDashboardHostname()}`); + console.log(`Network: ${config.docker_network}`); + console.log(""); + + const routes = router.getRoutes(); + if (routes.size === 0) { + console.log("No target routes configured."); + console.log("Install a target with: katana install "); + } else { + console.log("Configured Routes:"); + for (const [hostname, route] of routes) { + console.log(` https://${hostname}`); + console.log(` -> ${route.containerName}:${route.port}`); + } + } + } catch (error) { + handleError(error); + } + }); +} diff --git a/src/commands/remove.ts b/src/commands/remove.ts new file mode 100644 index 0000000..6ca878a --- /dev/null +++ b/src/commands/remove.ts @@ -0,0 +1,71 @@ +import { getComposeManager } from "../core/compose-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { getToolExecutor } from "../core/tool-executor.ts"; +import { NotFoundError, SystemLockedError } from "../types/errors.ts"; +import { isTargetModule, isToolModule } from "../types/module.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Remove an installed target or tool + */ +export async function removeCommand(name: string): Promise { + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + + // Check Docker connectivity first + const docker = getDockerClient(); + await docker.checkPermissions(); + + // Check if system is locked + if (await stateManager.isLocked()) { + throw new SystemLockedError(); + } + + // Check if installed (check both targets and tools) + const target = await stateManager.findTarget(name); + const tool = await stateManager.findTool(name); + + if (!target && !tool) { + throw new NotFoundError("Installed module", name); + } + + // Load module to get path + const module = await moduleLoader.findModule(name); + if (!module) { + throw new NotFoundError("Module", name); + } + + if (!module.path) { + throw new NotFoundError("Module path", name); + } + + // Handle based on category + if (isTargetModule(module)) { + logger.info(`Removing target: ${name}`); + + // Run docker compose down + const composeManager = await getComposeManager(); + await composeManager.down(name, module.path); + + // Remove from state + await stateManager.removeTarget(name); + + logger.success(`Target ${name} removed successfully`); + } else if (isToolModule(module)) { + logger.info(`Removing tool: ${name}`); + + // Execute remove script + const toolExecutor = getToolExecutor(); + await toolExecutor.executeRemove(module); + + // Remove from state + await stateManager.removeTool(name); + + logger.success(`Tool ${name} removed successfully`); + } else { + logger.error("Unknown module category"); + process.exit(1); + } +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..0aec392 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,115 @@ +import type { Command } from "commander"; +import { handleError } from "../cli.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Register setup commands + */ +export function registerSetupCommands(program: Command): void { + program + .command("setup-proxy") + .description("Configure system for proxy operation (requires sudo)") + .action(async () => { + try { + await setupProxy(); + } catch (error) { + handleError(error); + } + }); +} + +/** + * Set up proxy capabilities + */ +async function setupProxy(): Promise { + console.log("Setting up proxy capabilities..."); + console.log(""); + + // Try to get the actual binary path + // When running under sudo, process.argv[1] can be unreliable (e.g., Bun's virtual FS path) + let katanaPath = "katana"; + + try { + // 1. First try: Check if katana is in PATH + const whichProc = Bun.spawn(["which", "katana"], { + stdout: "pipe", + stderr: "pipe", + }); + const whichExit = await whichProc.exited; + const whichOutput = await new Response(whichProc.stdout).text(); + + if (whichExit === 0 && whichOutput.trim()) { + katanaPath = whichOutput.trim(); + } else if (process.env.SUDO_USER) { + // 2. Fallback for sudo: Try common locations based on original user + const sudoUser = process.env.SUDO_USER; + const possiblePaths = [ + `${process.cwd()}/katana`, // If run from bin/ directory + `${process.cwd()}/bin/katana`, // If run from project root + `/home/${sudoUser}/projects/katana/bin/katana`, // Common dev path + `/home/${sudoUser}/bin/katana`, // User's bin directory + ]; + + for (const path of possiblePaths) { + const file = Bun.file(path); + if (await file.exists()) { + katanaPath = path; + break; + } + } + } + } catch { + // Fall back to "katana" if resolution fails + } + + // Check if setcap is available + const setcapProc = Bun.spawn(["which", "setcap"], { + stdout: "pipe", + stderr: "pipe", + }); + const setcapExit = await setcapProc.exited; + + if (setcapExit !== 0) { + logger.error("setcap command not found."); + logger.error(""); + logger.error("Install it with:"); + logger.error(" sudo apt install libcap2-bin"); + process.exit(1); + } + + // Output the command for the user to run + const setcapCommand = `sudo setcap cap_net_bind_service=+ep ${katanaPath}`; + + console.log("To allow Katana to bind to port 443 without sudo, run:"); + console.log(""); + console.log(` ${setcapCommand}`); + console.log(""); + console.log("After running this command, you can start the proxy without sudo:"); + console.log(" katana proxy start"); + console.log(""); + console.log("Note: If you move the katana binary, you'll need to run setcap again."); + console.log(""); + + // If running as root, offer to apply it now + if (process.getuid?.() === 0) { + console.log("Detected root privileges. Applying capability now..."); + console.log(""); + + const proc = Bun.spawn(["setcap", "cap_net_bind_service=+ep", katanaPath], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + logger.error("Failed to set capabilities:", stderr.trim()); + logger.error(""); + logger.error("You may need to run the command manually with the correct path."); + process.exit(1); + } + + logger.success("Successfully configured port binding capability!"); + } +} diff --git a/src/commands/start.ts b/src/commands/start.ts new file mode 100644 index 0000000..c87b58d --- /dev/null +++ b/src/commands/start.ts @@ -0,0 +1,43 @@ +import { getComposeManager } from "../core/compose-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { NotFoundError } from "../types/errors.ts"; +import { isTargetModule } from "../types/module.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Start a stopped target + */ +export async function startCommand(name: string): Promise { + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + + // Check Docker connectivity first + const docker = getDockerClient(); + await docker.checkPermissions(); + + // Check if installed + const target = await stateManager.findTarget(name); + if (!target) { + throw new NotFoundError("Installed target", name); + } + + // Load module to get path + const module = await moduleLoader.findModule(name); + if (!module || !isTargetModule(module)) { + throw new NotFoundError("Module", name); + } + + if (!module.path) { + throw new NotFoundError("Module path", name); + } + + logger.info(`Starting target: ${name}`); + + // Run docker compose start + const composeManager = await getComposeManager(); + await composeManager.start(name, module.path); + + logger.success(`Target ${name} started`); +} diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 0000000..bb9c24f --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,43 @@ +import { getComposeManager } from "../core/compose-manager.ts"; +import { getDockerClient } from "../core/docker-client.ts"; +import { getModuleLoader } from "../core/module-loader.ts"; +import { getStateManager } from "../core/state-manager.ts"; +import { NotFoundError } from "../types/errors.ts"; +import { isTargetModule } from "../types/module.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Stop a running target + */ +export async function stopCommand(name: string): Promise { + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + + // Check Docker connectivity first + const docker = getDockerClient(); + await docker.checkPermissions(); + + // Check if installed + const target = await stateManager.findTarget(name); + if (!target) { + throw new NotFoundError("Installed target", name); + } + + // Load module to get path + const module = await moduleLoader.findModule(name); + if (!module || !isTargetModule(module)) { + throw new NotFoundError("Module", name); + } + + if (!module.path) { + throw new NotFoundError("Module path", name); + } + + logger.info(`Stopping target: ${name}`); + + // Run docker compose stop + const composeManager = await getComposeManager(); + await composeManager.stop(name, module.path); + + logger.success(`Target ${name} stopped`); +} diff --git a/src/core/cert-manager.ts b/src/core/cert-manager.ts new file mode 100644 index 0000000..9b3e609 --- /dev/null +++ b/src/core/cert-manager.ts @@ -0,0 +1,383 @@ +import { join } from "node:path"; +import YAML from "yaml"; +import { CertError, CertNotInitializedError, OpenSSLNotFoundError } from "../types/errors.ts"; +import { ensureDir, getCertsPath, resolvePath } from "../utils/paths.ts"; +import { getConfigManager } from "./config-manager.ts"; + +/** + * Certificate metadata stored alongside certs + */ +interface CertMetadata { + created_at: string; + domain: string; + ca_expires_at: string; + server_expires_at: string; +} + +/** + * Manages self-signed CA and server certificates + */ +export class CertManager { + private certsPath: string; + private metadata: CertMetadata | null = null; + + constructor(certsPath?: string) { + this.certsPath = resolvePath(certsPath ?? getCertsPath()); + } + + // File paths + private get caKeyPath(): string { + return join(this.certsPath, "ca.key"); + } + private get caCertPath(): string { + return join(this.certsPath, "ca.crt"); + } + private get serverKeyPath(): string { + return join(this.certsPath, "server.key"); + } + private get serverCertPath(): string { + return join(this.certsPath, "server.crt"); + } + private get metadataPath(): string { + return join(this.certsPath, "cert-metadata.yml"); + } + + /** + * Get the certificates directory path + */ + getPath(): string { + return this.certsPath; + } + + /** + * Get the CA certificate file path + */ + getCACertPath(): string { + return this.caCertPath; + } + + /** + * Check if CA has been initialized + */ + async isInitialized(): Promise { + const caKeyExists = await Bun.file(this.caKeyPath).exists(); + const caCertExists = await Bun.file(this.caCertPath).exists(); + return caKeyExists && caCertExists; + } + + /** + * Initialize CA and generate server certificate + * Creates CA if not exists, always regenerates server cert for current domain + */ + async initCA(): Promise { + await this.checkOpenSSL(); + await ensureDir(this.certsPath); + + const caExists = await this.isInitialized(); + + if (!caExists) { + // Generate CA key (4096-bit for long-lived CA) + await this.execOpenSSL(["genrsa", "-out", this.caKeyPath, "4096"]); + + // Generate self-signed CA certificate (10 years) + await this.execOpenSSL([ + "req", + "-new", + "-x509", + "-days", + "3650", + "-key", + this.caKeyPath, + "-out", + this.caCertPath, + "-subj", + "/CN=Katana CA/O=OWASP SamuraiWTF", + ]); + } + + // Generate server cert for current domain + const domain = await this.getWildcardDomain(); + await this.generateCert(domain); + } + + /** + * Generate wildcard server certificate for domain + */ + async generateCert(domain: string): Promise { + // Verify CA exists + if (!(await this.isInitialized())) { + throw new CertNotInitializedError(); + } + + await this.checkOpenSSL(); + + // Generate server key (2048-bit) + await this.execOpenSSL(["genrsa", "-out", this.serverKeyPath, "2048"]); + + // Create temporary CSR + const csrPath = join(this.certsPath, "server.csr"); + await this.execOpenSSL([ + "req", + "-new", + "-key", + this.serverKeyPath, + "-out", + csrPath, + "-subj", + `/CN=${domain}`, + ]); + + // Create OpenSSL config for SAN extension + const baseDomain = domain.replace("*.", ""); + const sanConfig = `[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = ${domain} + +[v3_req] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = ${domain} +DNS.2 = ${baseDomain} +`; + + const sanConfigPath = join(this.certsPath, "san.cnf"); + await Bun.write(sanConfigPath, sanConfig); + + // Sign server cert with CA (1 year validity) + await this.execOpenSSL([ + "x509", + "-req", + "-days", + "365", + "-in", + csrPath, + "-CA", + this.caCertPath, + "-CAkey", + this.caKeyPath, + "-CAcreateserial", + "-out", + this.serverCertPath, + "-extfile", + sanConfigPath, + "-extensions", + "v3_req", + ]); + + // Append CA cert to server cert to create full chain + // This is required for proper TLS handshake with self-signed CAs + const serverCert = await Bun.file(this.serverCertPath).text(); + const caCert = await Bun.file(this.caCertPath).text(); + await Bun.write(this.serverCertPath, `${serverCert}${caCert}`); + + // Clean up temp files + const serialPath = join(this.certsPath, "ca.srl"); + await Bun.spawn(["rm", "-f", csrPath, sanConfigPath, serialPath]).exited; + + // Save metadata + const now = new Date(); + const caExpires = new Date(now); + caExpires.setFullYear(caExpires.getFullYear() + 10); + + const serverExpires = new Date(now); + serverExpires.setFullYear(serverExpires.getFullYear() + 1); + + await this.saveMetadata({ + created_at: now.toISOString(), + domain, + ca_expires_at: caExpires.toISOString(), + server_expires_at: serverExpires.toISOString(), + }); + } + + /** + * Check if certificates exist and are valid + */ + async validateCerts(): Promise { + // Check all required files exist + const files = [this.caKeyPath, this.caCertPath, this.serverKeyPath, this.serverCertPath]; + + for (const file of files) { + if (!(await Bun.file(file).exists())) { + return false; + } + } + + // Check not expired + const days = await this.daysUntilExpiration(); + return days > 0; + } + + /** + * Get days until server certificate expiration + * Returns -1 if certs don't exist + */ + async daysUntilExpiration(): Promise { + if (!(await Bun.file(this.serverCertPath).exists())) { + return -1; + } + + try { + // Use openssl to get expiration date + const proc = Bun.spawn( + ["openssl", "x509", "-enddate", "-noout", "-in", this.serverCertPath], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + return -1; + } + + // Parse: "notAfter=Jan 4 12:00:00 2027 GMT" + const match = stdout.match(/notAfter=(.+)/); + if (!match?.[1]) return -1; + + const expiresAt = new Date(match[1].trim()); + const now = new Date(); + const diffMs = expiresAt.getTime() - now.getTime(); + + return Math.floor(diffMs / (1000 * 60 * 60 * 24)); + } catch { + return -1; + } + } + + /** + * Export CA certificate to destination path + */ + async exportCA(destPath: string): Promise { + if (!(await Bun.file(this.caCertPath).exists())) { + throw new CertNotInitializedError(); + } + + const resolvedDest = resolvePath(destPath); + const content = await Bun.file(this.caCertPath).text(); + await Bun.write(resolvedDest, content); + } + + /** + * Get TLS options for Bun.serve() + */ + async getTLSOptions(): Promise<{ cert: string; key: string; ca: string }> { + if (!(await this.validateCerts())) { + throw new CertNotInitializedError(); + } + + const [cert, key, ca] = await Promise.all([ + Bun.file(this.serverCertPath).text(), + Bun.file(this.serverKeyPath).text(), + Bun.file(this.caCertPath).text(), + ]); + + return { cert, key, ca }; + } + + /** + * Renew server certificate (keeps same CA) + */ + async renewCert(): Promise { + if (!(await this.isInitialized())) { + throw new CertNotInitializedError(); + } + + const domain = await this.getWildcardDomain(); + await this.generateCert(domain); + } + + /** + * Load metadata from disk + */ + private async loadMetadata(): Promise { + const file = Bun.file(this.metadataPath); + if (!(await file.exists())) { + return null; + } + + try { + const content = await file.text(); + return YAML.parse(content) as CertMetadata; + } catch { + return null; + } + } + + /** + * Save metadata to disk + */ + private async saveMetadata(metadata: CertMetadata): Promise { + const content = YAML.stringify(metadata); + await Bun.write(this.metadataPath, content); + this.metadata = metadata; + } + + /** + * Execute OpenSSL command + */ + private async execOpenSSL(args: string[]): Promise { + const proc = Bun.spawn(["openssl", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new CertError(`OpenSSL command failed: ${stderr}`); + } + } + + /** + * Check if OpenSSL is available + */ + private async checkOpenSSL(): Promise { + const proc = Bun.spawn(["which", "openssl"], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new OpenSSLNotFoundError(); + } + } + + /** + * Get wildcard domain from config + */ + private async getWildcardDomain(): Promise { + const configManager = getConfigManager(); + const config = await configManager.get(); + + if (config.install_type === "remote" && config.base_domain) { + return `*.${config.base_domain}`; + } + return `*.${config.local_domain}`; + } +} + +// Default singleton instance +let defaultInstance: CertManager | null = null; + +/** + * Get the default CertManager instance + */ +export function getCertManager(): CertManager { + if (defaultInstance === null) { + defaultInstance = new CertManager(); + } + return defaultInstance; +} diff --git a/src/core/compose-manager.ts b/src/core/compose-manager.ts new file mode 100644 index 0000000..69a9580 --- /dev/null +++ b/src/core/compose-manager.ts @@ -0,0 +1,270 @@ +import { join } from "node:path"; +import type { ComposeStatus, ContainerInfo } from "../types/docker.ts"; +import { DockerError } from "../types/errors.ts"; +import type { TargetModule } from "../types/module.ts"; +import { getConfigManager } from "./config-manager.ts"; +import { type DockerClient, getDockerClient } from "./docker-client.ts"; + +/** + * Manages Docker Compose operations for target modules + */ +export class ComposeManager { + private docker: DockerClient; + private network: string; + + constructor(network: string) { + this.docker = getDockerClient(); + this.network = network; + } + + /** + * Ensure the shared Docker network exists + */ + async ensureNetwork(): Promise { + const created = await this.docker.ensureNetwork(this.network); + if (created) { + console.log(`Created Docker network: ${this.network}`); + } + } + + /** + * Get the compose project name for a module + * Convention: katana- + */ + getProjectName(moduleName: string): string { + return `katana-${moduleName}`; + } + + /** + * Render compose template with environment variables + * Returns path to the rendered file (or original if no templating needed) + */ + async renderTemplate(module: TargetModule, vars: Record): Promise { + if (!module.path) { + throw new DockerError("Module path not set"); + } + const composePath = join(module.path, module.compose); + const template = await Bun.file(composePath).text(); + + // Check if template contains any ${VAR} patterns + if (!template.includes("${")) { + return composePath; // No templating needed + } + + // Perform variable substitution + let rendered = template; + for (const [key, value] of Object.entries(vars)) { + rendered = rendered.replaceAll(`\${${key}}`, value); + } + + // Write rendered file alongside the original + const renderedPath = join(module.path, "compose.rendered.yml"); + await Bun.write(renderedPath, rendered); + return renderedPath; + } + + /** + * Start a compose project (docker compose up -d) + */ + async up(module: TargetModule, envOverride?: Record): Promise { + // Ensure network exists first + await this.ensureNetwork(); + + // Determine compose file path (render if needed) + // Use override if provided, otherwise fall back to module.env + const env = envOverride ?? module.env ?? {}; + const composePath = await this.renderTemplate(module, env); + const projectName = this.getProjectName(module.name); + + // Run docker compose up (--no-start to create containers without starting) + const proc = Bun.spawn( + ["docker", "compose", "-f", composePath, "-p", projectName, "up", "-d", "--no-start"], + { + cwd: module.path, + stdout: "inherit", + stderr: "inherit", + }, + ); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new DockerError(`docker compose up failed with exit code ${exitCode}`); + } + } + + /** + * Stop and remove a compose project (docker compose down) + */ + async down(moduleName: string, modulePath: string): Promise { + const projectName = this.getProjectName(moduleName); + + // Try rendered file first, fall back to compose.yml + let composePath = join(modulePath, "compose.rendered.yml"); + if (!(await Bun.file(composePath).exists())) { + composePath = join(modulePath, "compose.yml"); + } + + const proc = Bun.spawn(["docker", "compose", "-f", composePath, "-p", projectName, "down"], { + cwd: modulePath, + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new DockerError(`docker compose down failed with exit code ${exitCode}`); + } + + // Clean up rendered file if it exists + const renderedPath = join(modulePath, "compose.rendered.yml"); + if (await Bun.file(renderedPath).exists()) { + await Bun.spawn(["rm", renderedPath]).exited; + } + } + + /** + * Start stopped containers (docker compose start) + */ + async start(moduleName: string, modulePath: string): Promise { + const projectName = this.getProjectName(moduleName); + + // Try rendered file first, fall back to compose.yml + let composePath = join(modulePath, "compose.rendered.yml"); + if (!(await Bun.file(composePath).exists())) { + composePath = join(modulePath, "compose.yml"); + } + + const proc = Bun.spawn(["docker", "compose", "-f", composePath, "-p", projectName, "start"], { + cwd: modulePath, + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new DockerError(`docker compose start failed with exit code ${exitCode}`); + } + } + + /** + * Stop running containers (docker compose stop) + */ + async stop(moduleName: string, modulePath: string): Promise { + const projectName = this.getProjectName(moduleName); + + // Try rendered file first, fall back to compose.yml + let composePath = join(modulePath, "compose.rendered.yml"); + if (!(await Bun.file(composePath).exists())) { + composePath = join(modulePath, "compose.yml"); + } + + const proc = Bun.spawn(["docker", "compose", "-f", composePath, "-p", projectName, "stop"], { + cwd: modulePath, + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new DockerError(`docker compose stop failed with exit code ${exitCode}`); + } + } + + /** + * Get project status by querying Docker for containers + */ + async status(moduleName: string): Promise { + const projectName = this.getProjectName(moduleName); + + // Query containers by compose project label + const containers = await this.docker.listContainers({ + all: true, + filters: { + label: [`com.docker.compose.project=${projectName}`], + }, + }); + + const allRunning = containers.length > 0 && containers.every((c: ContainerInfo) => c.running); + const anyRunning = containers.some((c: ContainerInfo) => c.running); + + return { + project: projectName, + containers, + all_running: allRunning, + any_running: anyRunning, + }; + } + + /** + * Stream logs from a compose project + */ + async logs( + moduleName: string, + modulePath: string, + options?: { follow?: boolean; tail?: number }, + ): Promise { + const projectName = this.getProjectName(moduleName); + + // Try rendered file first, fall back to compose.yml + let composePath = join(modulePath, "compose.rendered.yml"); + if (!(await Bun.file(composePath).exists())) { + composePath = join(modulePath, "compose.yml"); + } + + const args = ["docker", "compose", "-f", composePath, "-p", projectName, "logs"]; + + if (options?.follow) { + args.push("--follow"); + } + if (options?.tail !== undefined) { + args.push("--tail", options.tail.toString()); + } + + const proc = Bun.spawn(args, { + cwd: modulePath, + stdout: "inherit", + stderr: "inherit", + }); + + await proc.exited; + } + + /** + * List all Katana-managed compose projects + */ + async listProjects(): Promise { + // Query all containers with katana compose project prefix + const containers = await this.docker.listContainers({ + all: true, + filters: { + label: ["com.docker.compose.project"], + }, + }); + + // Extract unique project names that start with 'katana-' + const projects = new Set(); + for (const container of containers) { + const project = container.labels["com.docker.compose.project"]; + if (project?.startsWith("katana-")) { + projects.add(project); + } + } + + return Array.from(projects); + } +} + +// Default singleton instance +let defaultInstance: ComposeManager | null = null; + +/** + * Get the default ComposeManager instance + */ +export async function getComposeManager(): Promise { + if (defaultInstance === null) { + const configManager = getConfigManager(); + const config = await configManager.get(); + defaultInstance = new ComposeManager(config.docker_network); + } + return defaultInstance; +} diff --git a/src/core/config-manager.ts b/src/core/config-manager.ts new file mode 100644 index 0000000..07f05a2 --- /dev/null +++ b/src/core/config-manager.ts @@ -0,0 +1,102 @@ +import YAML from "yaml"; +import { type Config, DEFAULT_CONFIG, parseConfig } from "../types/config.ts"; +import { ConfigError } from "../types/errors.ts"; +import { ensureParentDir, getConfigPath, resolvePath } from "../utils/paths.ts"; + +/** + * Manages system configuration + */ +export class ConfigManager { + private configPath: string; + private config: Config | null = null; + + constructor(configPath?: string) { + this.configPath = resolvePath(configPath ?? getConfigPath()); + } + + /** + * Load configuration from disk + * Creates default config if not exists + */ + async load(): Promise { + const file = Bun.file(this.configPath); + const exists = await file.exists(); + + if (!exists) { + // Create default config + await this.save(DEFAULT_CONFIG); + this.config = DEFAULT_CONFIG; + return this.config; + } + + try { + const content = await file.text(); + const data = YAML.parse(content); + this.config = parseConfig(data); + return this.config; + } catch (error) { + if (error instanceof Error) { + throw new ConfigError(`Failed to load config: ${error.message}`); + } + throw new ConfigError("Failed to load config: Unknown error"); + } + } + + /** + * Save configuration to disk + */ + async save(config: Config): Promise { + try { + await ensureParentDir(this.configPath); + const content = YAML.stringify(config); + await Bun.write(this.configPath, content); + this.config = config; + } catch (error) { + if (error instanceof Error) { + throw new ConfigError(`Failed to save config: ${error.message}`); + } + throw new ConfigError("Failed to save config: Unknown error"); + } + } + + /** + * Get current configuration (loads if not cached) + */ + async get(): Promise { + if (this.config === null) { + return this.load(); + } + return this.config; + } + + /** + * Get the config file path + */ + getPath(): string { + return this.configPath; + } +} + +// Default singleton instance +let defaultInstance: ConfigManager | null = null; + +/** + * Initialize the default ConfigManager instance with a custom path + * Must be called before first getConfigManager() call + */ +export function initConfigManager(configPath?: string): void { + if (defaultInstance !== null) { + throw new ConfigError("ConfigManager already initialized"); + } + defaultInstance = new ConfigManager(configPath); +} + +/** + * Get the default ConfigManager instance + */ +export function getConfigManager(): ConfigManager { + if (defaultInstance === null) { + defaultInstance = new ConfigManager(); + } + return defaultInstance; +} diff --git a/src/core/docker-client.ts b/src/core/docker-client.ts new file mode 100644 index 0000000..6f7292d --- /dev/null +++ b/src/core/docker-client.ts @@ -0,0 +1,463 @@ +import type { ContainerInfo } from "../types/docker.ts"; +import { DockerError, DockerNotRunningError, DockerPermissionError } from "../types/errors.ts"; + +/** + * Docker client using CLI commands instead of dockerode + * (Avoids Bun compatibility issues with native modules) + */ +export class DockerClient { + /** + * Check if Docker daemon is running and accessible + */ + async ping(): Promise { + try { + const proc = Bun.spawn(["docker", "info"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } + } + + /** + * Check if user has Docker permissions + * @throws DockerNotRunningError if daemon is not running + * @throws DockerPermissionError if permission denied + */ + async checkPermissions(): Promise { + try { + const proc = Bun.spawn(["docker", "info"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + const message = stderr.toLowerCase(); + + if ( + message.includes("cannot connect") || + message.includes("is the docker daemon running") + ) { + throw new DockerNotRunningError(); + } + if (message.includes("permission denied") || message.includes("got permission denied")) { + throw new DockerPermissionError(); + } + throw new DockerError(`Docker error: ${stderr}`); + } + return true; + } catch (error) { + if (error instanceof DockerError) { + throw error; + } + throw new DockerError(`Failed to connect to Docker: ${error}`); + } + } + + /** + * Check if a network exists + */ + async networkExists(name: string): Promise { + try { + const proc = Bun.spawn(["docker", "network", "inspect", name], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } + } + + /** + * Ensure Docker network exists, creating if needed + * @returns true if network was created, false if it already existed + */ + async ensureNetwork(name: string): Promise { + const exists = await this.networkExists(name); + if (exists) { + return false; + } + + try { + const proc = Bun.spawn(["docker", "network", "create", name], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new DockerError(`Failed to create network '${name}': ${stderr}`); + } + return true; + } catch (error) { + if (error instanceof DockerError) { + throw error; + } + throw new DockerError(`Failed to create network '${name}': ${error}`); + } + } + + /** + * List containers with optional filters + */ + async listContainers(options?: { + all?: boolean; + filters?: { + label?: string[]; + name?: string[]; + network?: string[]; + }; + }): Promise { + try { + const args = ["docker", "ps", "--format", "{{json .}}"]; + + if (options?.all) { + args.push("-a"); + } + + if (options?.filters?.label) { + for (const label of options.filters.label) { + args.push("--filter", `label=${label}`); + } + } + if (options?.filters?.name) { + for (const name of options.filters.name) { + args.push("--filter", `name=${name}`); + } + } + if (options?.filters?.network) { + for (const network of options.filters.network) { + args.push("--filter", `network=${network}`); + } + } + + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new DockerError(`Failed to list containers: ${stderr}`); + } + + // Parse JSON lines output + const containers: ContainerInfo[] = []; + const lines = stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + try { + const data = JSON.parse(line); + containers.push(this.parseContainerJson(data)); + } catch { + // Skip malformed lines + } + } + + return containers; + } catch (error) { + if (error instanceof DockerError) { + throw error; + } + throw new DockerError(`Failed to list containers: ${error}`); + } + } + + /** + * Get a single container by name or ID + */ + async getContainer(nameOrId: string): Promise { + try { + const proc = Bun.spawn(["docker", "inspect", "--format", "json", nameOrId], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + return null; + } + + const data = JSON.parse(stdout); + if (Array.isArray(data) && data.length > 0) { + return this.parseInspectJson(data[0]); + } + return null; + } catch { + return null; + } + } + + /** + * Parse docker ps --format json output + */ + private parseContainerJson(data: Record): ContainerInfo { + const state = String(data.State || "unknown").toLowerCase() as ContainerInfo["state"]; + const running = state === "running"; + + // Parse RunningFor to get uptime in seconds (approximate) + let uptime = 0; + if (running && data.RunningFor) { + uptime = this.parseRunningFor(String(data.RunningFor)); + } + + // Parse Labels string to object + const labels: Record = {}; + if (data.Labels) { + const labelStr = String(data.Labels); + for (const pair of labelStr.split(",")) { + const [key, value] = pair.split("="); + if (key) { + labels[key] = value || ""; + } + } + } + + // Parse Networks + const networks: string[] = []; + if (data.Networks) { + networks.push(...String(data.Networks).split(",")); + } + + return { + id: String(data.ID || "").substring(0, 12), + name: String(data.Names || ""), + image: String(data.Image || ""), + running, + state, + uptime, + networks, + labels, + }; + } + + /** + * Parse docker inspect output + */ + private parseInspectJson(data: Record): ContainerInfo { + const stateData = data.State as Record | undefined; + const state = String(stateData?.Status || "unknown").toLowerCase() as ContainerInfo["state"]; + const running = stateData?.Running === true; + + // Calculate uptime from StartedAt + let uptime = 0; + if (running && stateData?.StartedAt) { + const startedAt = new Date(String(stateData.StartedAt)).getTime(); + uptime = Math.floor((Date.now() - startedAt) / 1000); + } + + // Extract networks + const networkSettings = data.NetworkSettings as Record | undefined; + const networksObj = networkSettings?.Networks as Record | undefined; + const networks = networksObj ? Object.keys(networksObj) : []; + + // Extract labels + const config = data.Config as Record | undefined; + const labels = (config?.Labels as Record) || {}; + + return { + id: String(data.Id || "").substring(0, 12), + name: String(data.Name || "").replace(/^\//, ""), + image: String(config?.Image || ""), + running, + state, + uptime, + networks, + labels, + }; + } + + /** + * Get container IP address on a specific Docker network + * @returns IP address string or null if container not on network + */ + async getContainerIPOnNetwork( + containerName: string, + networkName: string, + ): Promise { + try { + // Use index notation to handle network names with hyphens + const formatStr = `{{index .NetworkSettings.Networks "${networkName}" "IPAddress"}}`; + const proc = Bun.spawn(["docker", "inspect", containerName, "--format", formatStr], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + return null; + } + + const ip = stdout.trim(); + + // Docker returns empty string or "" if container not on network + if (!ip || ip === "") { + return null; + } + + return ip; + } catch { + return null; + } + } + + /** + * Check if a container is running + */ + async isContainerRunning(containerName: string): Promise { + try { + const proc = Bun.spawn( + ["docker", "inspect", containerName, "--format", "{{.State.Running}}"], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + return false; + } + + return stdout.trim() === "true"; + } catch { + return false; + } + } + + /** + * List all Katana-managed containers (from Docker Compose projects starting with "katana-") + */ + async listKatanaContainers(): Promise { + try { + // List all containers (including stopped) with compose project label + const containers = await this.listContainers({ + all: true, + filters: { + label: ["com.docker.compose.project"], + }, + }); + + // Filter to only katana- prefixed projects + return containers.filter((c) => { + const project = c.labels["com.docker.compose.project"]; + return project?.startsWith("katana-"); + }); + } catch { + return []; + } + } + + /** + * Remove a container by name or ID + */ + async removeContainer( + nameOrId: string, + options?: { force?: boolean; volumes?: boolean }, + ): Promise { + const args = ["docker", "rm"]; + + if (options?.force) { + args.push("-f"); + } + if (options?.volumes) { + args.push("-v"); + } + + args.push(nameOrId); + + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new DockerError(`Failed to remove container '${nameOrId}': ${stderr}`); + } + } + + /** + * Prune unused Docker images + * @returns Space reclaimed in bytes (approximate) + */ + async pruneImages(): Promise { + const proc = Bun.spawn(["docker", "image", "prune", "-f"], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new DockerError(`Failed to prune images: ${stderr}`); + } + + // Parse output for space reclaimed + // Output format: "Total reclaimed space: 1.234GB" + const match = stdout.match(/Total reclaimed space:\s*(.+)/); + return match?.[1]?.trim() ?? "0B"; + } + + /** + * Parse "X minutes ago" or "X hours ago" to seconds + */ + private parseRunningFor(runningFor: string): number { + const match = runningFor.match(/(\d+)\s*(second|minute|hour|day|week|month)/i); + if (!match || !match[1] || !match[2]) return 0; + + const value = Number.parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + switch (unit) { + case "second": + return value; + case "minute": + return value * 60; + case "hour": + return value * 3600; + case "day": + return value * 86400; + case "week": + return value * 604800; + case "month": + return value * 2592000; + default: + return 0; + } + } +} + +// Default singleton instance +let defaultInstance: DockerClient | null = null; + +/** + * Get the default DockerClient instance + */ +export function getDockerClient(): DockerClient { + if (defaultInstance === null) { + defaultInstance = new DockerClient(); + } + return defaultInstance; +} diff --git a/src/core/module-loader.ts b/src/core/module-loader.ts new file mode 100644 index 0000000..6d6570b --- /dev/null +++ b/src/core/module-loader.ts @@ -0,0 +1,180 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import YAML from "yaml"; +import { ModuleError, NotFoundError } from "../types/errors.ts"; +import { type Module, parseModule } from "../types/module.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { getConfigManager } from "./config-manager.ts"; + +/** + * Loads and validates module definitions from the modules directory + */ +export class ModuleLoader { + private modulesPath: string; + private cache: Map = new Map(); + + constructor(modulesPath: string) { + this.modulesPath = resolvePath(modulesPath); + } + + /** + * Scan and load all modules (targets and tools) + */ + async loadAll(): Promise { + const targets = await this.loadByCategory("targets"); + const tools = await this.loadByCategory("tools"); + return [...targets, ...tools]; + } + + /** + * Load modules by category + */ + async loadByCategory(category: "targets" | "tools"): Promise { + const categoryPath = join(this.modulesPath, category); + const modules: Module[] = []; + + try { + const entries = await readdir(categoryPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const modulePath = join(categoryPath, entry.name); + const isValid = await this.validateModuleDir(modulePath); + + if (isValid) { + try { + const module = await this.parseModuleFile(modulePath); + modules.push(module); + } catch (error) { + // Log warning but continue loading other modules + console.warn( + `Warning: Failed to load module at ${modulePath}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + } + } catch (error) { + // Directory doesn't exist or can't be read - return empty array + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + return modules; + } + + /** + * Load a specific module by name + * @throws NotFoundError if module doesn't exist + */ + async loadModule(name: string): Promise { + // Check cache first + const cached = this.cache.get(name); + if (cached) return cached; + + // Search in targets and tools directories + for (const category of ["targets", "tools"] as const) { + const modulePath = join(this.modulesPath, category, name); + const isValid = await this.validateModuleDir(modulePath); + + if (isValid) { + return this.parseModuleFile(modulePath); + } + } + + throw new NotFoundError("Module", name); + } + + /** + * Find a module by name (returns undefined if not found) + */ + async findModule(name: string): Promise { + try { + return await this.loadModule(name); + } catch (error) { + if (error instanceof NotFoundError) { + return undefined; + } + throw error; + } + } + + /** + * Clear the module cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get the modules directory path + */ + getPath(): string { + return this.modulesPath; + } + + /** + * Validate that a directory contains a valid module structure + */ + private async validateModuleDir(dirPath: string): Promise { + const moduleYmlPath = join(dirPath, "module.yml"); + const file = Bun.file(moduleYmlPath); + return file.exists(); + } + + /** + * Parse and validate a module.yml file + */ + private async parseModuleFile(modulePath: string): Promise { + const moduleYmlPath = join(modulePath, "module.yml"); + + try { + const content = await Bun.file(moduleYmlPath).text(); + const data = YAML.parse(content); + + // Parse and validate with Zod + const module = parseModule(data); + + // Set the module path (not in YAML, set by loader) + module.path = modulePath; + + // Cache the module + this.cache.set(module.name, module); + + return module; + } catch (error) { + if (error instanceof Error) { + throw new ModuleError( + `Failed to parse module at ${modulePath}: ${error.message}`, + modulePath, + ); + } + throw new ModuleError(`Failed to parse module at ${modulePath}`, modulePath); + } + } +} + +// Default singleton instance +let defaultInstance: ModuleLoader | null = null; + +/** + * Get the default ModuleLoader instance + * Uses the modules path from configuration + */ +export async function getModuleLoader(): Promise { + if (defaultInstance === null) { + const configManager = getConfigManager(); + const config = await configManager.get(); + defaultInstance = new ModuleLoader(config.paths.modules); + } + return defaultInstance; +} + +/** + * Create a ModuleLoader with a specific path (for testing) + */ +export function createModuleLoader(modulesPath: string): ModuleLoader { + return new ModuleLoader(modulesPath); +} diff --git a/src/core/operation-manager.ts b/src/core/operation-manager.ts new file mode 100644 index 0000000..cad5f3c --- /dev/null +++ b/src/core/operation-manager.ts @@ -0,0 +1,743 @@ +/** + * OperationManager - tracks async module operations and bridges to SSE + * + * This module provides a singleton that manages asynchronous operations + * (install, remove, start, stop) and broadcasts progress to SSE subscribers. + */ + +import { join } from "node:path"; +import { type SSEEvent, formatSSEMessage, sendSSEEvent } from "../server/sse.ts"; +import { type Config, getTargetHostname } from "../types/config.ts"; +import { DockerError } from "../types/errors.ts"; +import type { TargetModule } from "../types/module.ts"; +import type { ProxyRoute, TargetState } from "../types/state.ts"; +import { logger } from "../utils/logger.ts"; +import { getComposeManager } from "./compose-manager.ts"; +import { getConfigManager } from "./config-manager.ts"; +import { getDockerClient } from "./docker-client.ts"; +import { getModuleLoader } from "./module-loader.ts"; +import { getStateManager } from "./state-manager.ts"; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Build environment variables with full hostnames from configured domain + * Transforms variables ending in _HOST to use getTargetHostname() + */ +function buildEnvWithDomain( + moduleEnv: Record | undefined, + config: Config, +): Record { + if (!moduleEnv) return {}; + + const result: Record = {}; + for (const [key, value] of Object.entries(moduleEnv)) { + // Transform hostname variables to use configured domain + if (key.endsWith("_HOST")) { + result[key] = getTargetHostname(config, value); + } else { + result[key] = value; + } + } + return result; +} + +// ============================================================================= +// Types +// ============================================================================= + +export type OperationType = "install" | "remove" | "start" | "stop"; +export type OperationStatus = "queued" | "running" | "completed" | "failed"; + +export interface TrackedOperation { + id: string; + module: string; + operation: OperationType; + status: OperationStatus; + startedAt: Date; + completedAt?: Date; + error?: string; + subscribers: Set>; +} + +export interface OperationResult { + success: boolean; + error?: string; + duration: number; // milliseconds +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEFAULT_OPERATION_TIMEOUT = 5 * 60 * 1000; // 5 minutes +const OPERATION_CLEANUP_AGE = 60 * 60 * 1000; // 1 hour + +// ============================================================================= +// OperationManager +// ============================================================================= + +let instance: OperationManager | null = null; + +export class OperationManager { + private operations = new Map(); + private moduleOperations = new Map(); // module -> operationId + private cleanupTimer?: ReturnType; + + private constructor() { + // Start cleanup timer + this.cleanupTimer = setInterval(() => this.cleanup(), OPERATION_CLEANUP_AGE / 2); + } + + /** + * Get the singleton instance + */ + static getInstance(): OperationManager { + if (!instance) { + instance = new OperationManager(); + } + return instance; + } + + /** + * Reset the singleton (for testing) + */ + static resetInstance(): void { + if (instance) { + if (instance.cleanupTimer) { + clearInterval(instance.cleanupTimer); + } + instance = null; + } + } + + /** + * Create and start a new operation + */ + async createOperation(moduleName: string, operation: OperationType): Promise { + // Generate operation ID + const id = crypto.randomUUID(); + + // Create tracked operation + const tracked: TrackedOperation = { + id, + module: moduleName, + operation, + status: "queued", + startedAt: new Date(), + subscribers: new Set(), + }; + + this.operations.set(id, tracked); + this.moduleOperations.set(moduleName.toLowerCase(), id); + + logger.info(`Operation created: ${id} (${operation} ${moduleName})`); + + // Start execution asynchronously (don't await) + this.executeOperation(tracked); + + return tracked; + } + + /** + * Get operation by ID + */ + getOperation(id: string): TrackedOperation | undefined { + return this.operations.get(id); + } + + /** + * Check if module has an operation in progress + */ + hasOperationInProgress(moduleName: string): boolean { + const operationId = this.moduleOperations.get(moduleName.toLowerCase()); + if (!operationId) return false; + + const operation = this.operations.get(operationId); + if (!operation) return false; + + return operation.status === "queued" || operation.status === "running"; + } + + /** + * Subscribe to operation events + */ + subscribe(operationId: string, controller: ReadableStreamDefaultController): boolean { + const operation = this.operations.get(operationId); + if (!operation) return false; + + operation.subscribers.add(controller); + + // If operation already completed, send completion event immediately + if (operation.status === "completed" || operation.status === "failed") { + const duration = operation.completedAt + ? operation.completedAt.getTime() - operation.startedAt.getTime() + : 0; + + sendSSEEvent(controller, { + type: "complete", + success: operation.status === "completed", + error: operation.error, + duration, + }); + } + + return true; + } + + /** + * Unsubscribe from operation events + */ + unsubscribe(operationId: string, controller: ReadableStreamDefaultController): void { + const operation = this.operations.get(operationId); + if (operation) { + operation.subscribers.delete(controller); + } + } + + /** + * Broadcast SSE event to all subscribers + */ + broadcast(operationId: string, event: SSEEvent): void { + const operation = this.operations.get(operationId); + if (!operation) return; + + const message = formatSSEMessage(event); + const encoder = new TextEncoder(); + const data = encoder.encode(message); + + for (const controller of operation.subscribers) { + try { + controller.enqueue(data); + } catch { + // Controller closed, will be cleaned up + operation.subscribers.delete(controller); + } + } + } + + /** + * Close all subscriber connections for an operation + */ + closeSubscribers(operationId: string): void { + const operation = this.operations.get(operationId); + if (!operation) return; + + for (const controller of operation.subscribers) { + try { + controller.close(); + } catch { + // Already closed + } + } + operation.subscribers.clear(); + } + + /** + * Execute an operation + */ + private async executeOperation(tracked: TrackedOperation): Promise { + // Set timeout + const timeoutHandle = setTimeout(() => { + if (tracked.status === "running") { + this.failOperation(tracked, "Operation timed out"); + } + }, DEFAULT_OPERATION_TIMEOUT); + + try { + tracked.status = "running"; + + // Broadcast initial progress + this.broadcast(tracked.id, { + type: "progress", + percent: 0, + message: `Starting ${tracked.operation}...`, + }); + + switch (tracked.operation) { + case "install": + await this.executeInstall(tracked); + break; + case "remove": + await this.executeRemove(tracked); + break; + case "start": + await this.executeStart(tracked); + break; + case "stop": + await this.executeStop(tracked); + break; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.failOperation(tracked, message); + } finally { + clearTimeout(timeoutHandle); + } + } + + /** + * Execute install operation + */ + private async executeInstall(tracked: TrackedOperation): Promise { + const moduleLoader = await getModuleLoader(); + const stateManager = getStateManager(); + const configManager = getConfigManager(); + const composeManager = await getComposeManager(); + const docker = getDockerClient(); + const config = await configManager.get(); + + // Task 1: Check Docker + this.broadcast(tracked.id, { + type: "task", + name: "Checking Docker connection", + status: "running", + }); + + await docker.checkPermissions(); + + this.broadcast(tracked.id, { + type: "task", + name: "Checking Docker connection", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 10, + message: "Docker connected", + }); + + // Task 2: Load module + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "running", + }); + + const module = await moduleLoader.findModule(tracked.module); + if (!module) { + throw new Error(`Module not found: ${tracked.module}`); + } + + if (module.category !== "targets") { + throw new Error("Only target modules can be installed via dashboard"); + } + + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 20, + message: "Module loaded", + }); + + // Task 3: Pull images and start containers + this.broadcast(tracked.id, { + type: "task", + name: "Starting containers", + status: "running", + }); + + this.broadcast(tracked.id, { + type: "log", + line: `Running docker compose up for ${module.name}`, + level: "info", + }); + + // Build environment variables with configured domain + const env = buildEnvWithDomain(module.env, config); + + // Run docker compose up with transformed environment + await this.runComposeCommand(module as TargetModule, tracked, "up", env); + + this.broadcast(tracked.id, { + type: "task", + name: "Starting containers", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 80, + message: "Containers started", + }); + + // Task 4: Update state + this.broadcast(tracked.id, { + type: "task", + name: "Updating state", + status: "running", + }); + + // Build routes for state + const routes: ProxyRoute[] = (module as TargetModule).proxy.map((p) => ({ + hostname: getTargetHostname(config, p.hostname), + service: p.service, + port: p.port, + })); + + const targetState: TargetState = { + name: module.name, + installed_at: new Date().toISOString(), + compose_project: composeManager.getProjectName(module.name), + routes, + }; + + await stateManager.addTarget(targetState); + + this.broadcast(tracked.id, { + type: "task", + name: "Updating state", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 100, + message: "Installation complete", + }); + + // Complete + this.completeOperation(tracked); + } + + /** + * Execute remove operation + */ + private async executeRemove(tracked: TrackedOperation): Promise { + const moduleLoader = await getModuleLoader(); + const stateManager = getStateManager(); + + // Task 1: Load module + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "running", + }); + + const module = await moduleLoader.findModule(tracked.module); + if (!module || module.category !== "targets") { + throw new Error(`Target not found: ${tracked.module}`); + } + + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 20, + message: "Module loaded", + }); + + // Task 2: Stop and remove containers + this.broadcast(tracked.id, { + type: "task", + name: "Removing containers", + status: "running", + }); + + await this.runComposeCommand(module as TargetModule, tracked, "down"); + + this.broadcast(tracked.id, { + type: "task", + name: "Removing containers", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 80, + message: "Containers removed", + }); + + // Task 3: Update state + this.broadcast(tracked.id, { + type: "task", + name: "Updating state", + status: "running", + }); + + await stateManager.removeTarget(tracked.module); + + this.broadcast(tracked.id, { + type: "task", + name: "Updating state", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 100, + message: "Removal complete", + }); + + this.completeOperation(tracked); + } + + /** + * Execute start operation + */ + private async executeStart(tracked: TrackedOperation): Promise { + const moduleLoader = await getModuleLoader(); + + // Task 1: Load module + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "running", + }); + + const module = await moduleLoader.findModule(tracked.module); + if (!module || module.category !== "targets") { + throw new Error(`Target not found: ${tracked.module}`); + } + + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "completed", + }); + + // Task 2: Start containers + this.broadcast(tracked.id, { + type: "task", + name: "Starting containers", + status: "running", + }); + + await this.runComposeCommand(module as TargetModule, tracked, "start"); + + this.broadcast(tracked.id, { + type: "task", + name: "Starting containers", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 100, + message: "Started", + }); + + this.completeOperation(tracked); + } + + /** + * Execute stop operation + */ + private async executeStop(tracked: TrackedOperation): Promise { + const moduleLoader = await getModuleLoader(); + + // Task 1: Load module + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "running", + }); + + const module = await moduleLoader.findModule(tracked.module); + if (!module || module.category !== "targets") { + throw new Error(`Target not found: ${tracked.module}`); + } + + this.broadcast(tracked.id, { + type: "task", + name: "Loading module", + status: "completed", + }); + + // Task 2: Stop containers + this.broadcast(tracked.id, { + type: "task", + name: "Stopping containers", + status: "running", + }); + + await this.runComposeCommand(module as TargetModule, tracked, "stop"); + + this.broadcast(tracked.id, { + type: "task", + name: "Stopping containers", + status: "completed", + }); + + this.broadcast(tracked.id, { + type: "progress", + percent: 100, + message: "Stopped", + }); + + this.completeOperation(tracked); + } + + /** + * Run a docker compose command with output capture + */ + private async runComposeCommand( + module: TargetModule, + tracked: TrackedOperation, + command: "up" | "down" | "start" | "stop", + envOverride?: Record, + ): Promise { + const composeManager = await getComposeManager(); + + if (!module.path) { + throw new DockerError("Module path not set"); + } + + // Determine compose file path + let composePath = join(module.path, "compose.rendered.yml"); + if (!(await Bun.file(composePath).exists())) { + composePath = join(module.path, module.compose); + } + + // For 'up' command, ensure network and render template + if (command === "up") { + await composeManager.ensureNetwork(); + // Use override if provided, otherwise fall back to module.env + const env = envOverride ?? module.env ?? {}; + composePath = await composeManager.renderTemplate(module, env); + } + + const projectName = composeManager.getProjectName(module.name); + + // Build command args + const args = + command === "up" + ? ["docker", "compose", "-f", composePath, "-p", projectName, "up", "-d", "--no-start"] + : ["docker", "compose", "-f", composePath, "-p", projectName, command]; + + // Run with captured output + const proc = Bun.spawn(args, { + cwd: module.path, + stdout: "pipe", + stderr: "pipe", + }); + + // Stream stdout + if (proc.stdout) { + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + for (const line of text.split("\n").filter((l) => l.trim())) { + this.broadcast(tracked.id, { type: "log", line, level: "info" }); + } + } + } finally { + reader.releaseLock(); + } + } + + // Stream stderr + if (proc.stderr) { + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + for (const line of text.split("\n").filter((l) => l.trim())) { + this.broadcast(tracked.id, { type: "log", line, level: "error" }); + } + } + } finally { + reader.releaseLock(); + } + } + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new DockerError(`docker compose ${command} failed with exit code ${exitCode}`); + } + + // Clean up rendered file after 'down' command + if (command === "down") { + const renderedPath = join(module.path, "compose.rendered.yml"); + if (await Bun.file(renderedPath).exists()) { + await Bun.spawn(["rm", renderedPath]).exited; + } + } + } + + /** + * Mark operation as completed + */ + private completeOperation(tracked: TrackedOperation): void { + tracked.status = "completed"; + tracked.completedAt = new Date(); + + const duration = tracked.completedAt.getTime() - tracked.startedAt.getTime(); + + logger.info(`Operation completed: ${tracked.id} (${duration}ms)`); + + this.broadcast(tracked.id, { + type: "complete", + success: true, + duration, + }); + + // Close subscribers after a short delay to ensure they receive the complete event + setTimeout(() => this.closeSubscribers(tracked.id), 100); + } + + /** + * Mark operation as failed + */ + private failOperation(tracked: TrackedOperation, error: string): void { + tracked.status = "failed"; + tracked.completedAt = new Date(); + tracked.error = error; + + const duration = tracked.completedAt.getTime() - tracked.startedAt.getTime(); + + logger.error(`Operation failed: ${tracked.id} - ${error}`); + + this.broadcast(tracked.id, { + type: "complete", + success: false, + error, + duration, + }); + + // Close subscribers after a short delay + setTimeout(() => this.closeSubscribers(tracked.id), 100); + } + + /** + * Clean up old completed operations + */ + cleanup(maxAge = OPERATION_CLEANUP_AGE): void { + const now = Date.now(); + + for (const [id, operation] of this.operations) { + if (operation.status === "completed" || operation.status === "failed") { + if (operation.completedAt && now - operation.completedAt.getTime() > maxAge) { + this.operations.delete(id); + this.moduleOperations.delete(operation.module.toLowerCase()); + } + } + } + } +} + +/** + * Get the singleton OperationManager instance + */ +export function getOperationManager(): OperationManager { + return OperationManager.getInstance(); +} diff --git a/src/core/proxy-router.ts b/src/core/proxy-router.ts new file mode 100644 index 0000000..a74c420 --- /dev/null +++ b/src/core/proxy-router.ts @@ -0,0 +1,147 @@ +import { getDashboardHostname } from "../types/config.ts"; +import type { TargetState } from "../types/state.ts"; +import { getConfigManager } from "./config-manager.ts"; +import { getStateManager } from "./state-manager.ts"; + +/** + * Resolved route information for proxying + */ +export interface ResolvedRoute { + /** Container name (e.g., "katana-dvwa-dvwa-1") */ + containerName: string; + /** Port inside the container */ + port: number; + /** Compose project name */ + composeProject: string; + /** Service name from compose file */ + service: string; + /** Target name for display */ + targetName: string; +} + +/** + * Route lookup result + */ +export type RouteResult = + | { type: "dashboard" } + | { type: "target"; route: ResolvedRoute } + | { type: "not_found" }; + +/** + * Routes incoming requests to appropriate targets based on hostname + */ +export class ProxyRouter { + private routes: Map = new Map(); + private dashboardHostname = ""; + + /** + * Load routes from state file + * Called on startup and when targets change + */ + async loadRoutes(): Promise { + const stateManager = getStateManager(); + const configManager = getConfigManager(); + + const state = await stateManager.get(); + const config = await configManager.get(); + + // Build dashboard hostname + this.dashboardHostname = getDashboardHostname(config).toLowerCase(); + + // Clear existing routes + this.routes.clear(); + + // Build routes from installed targets + for (const target of state.targets) { + this.addTargetRoutes(target); + } + } + + /** + * Add routes for a target + */ + private addTargetRoutes(target: TargetState): void { + for (const route of target.routes) { + // Routes in state already have full hostname (e.g., "dvwa.test") + const hostname = route.hostname.toLowerCase(); + + // Container name follows Docker Compose V2 convention: + // -- + const containerName = `${target.compose_project}-${route.service}-1`; + + this.routes.set(hostname, { + containerName, + port: route.port, + composeProject: target.compose_project, + service: route.service, + targetName: target.name, + }); + } + } + + /** + * Resolve hostname to route + */ + resolve(hostname: string): RouteResult { + // Strip port if present (e.g., "dvwa.test:443" -> "dvwa.test") + const hostPart = hostname.split(":")[0]; + const normalizedHost = (hostPart || hostname).toLowerCase(); + + // Check for dashboard + if (normalizedHost === this.dashboardHostname) { + return { type: "dashboard" }; + } + + // Check for target route + const route = this.routes.get(normalizedHost); + if (route) { + return { type: "target", route }; + } + + return { type: "not_found" }; + } + + /** + * Get all registered routes (for status display) + */ + getRoutes(): Map { + return new Map(this.routes); + } + + /** + * Get dashboard hostname + */ + getDashboardHostname(): string { + return this.dashboardHostname; + } + + /** + * Reload routes from state + */ + async reload(): Promise { + await this.loadRoutes(); + } +} + +// Singleton instance +let routerInstance: ProxyRouter | null = null; + +/** + * Get the ProxyRouter instance (creates if needed, always reloads routes) + * Routes are reloaded on each call to pick up target installs/removals + */ +export async function getProxyRouter(): Promise { + if (!routerInstance) { + routerInstance = new ProxyRouter(); + } + // Always reload routes to pick up changes from CLI + await routerInstance.loadRoutes(); + return routerInstance; +} + +/** + * Reset the ProxyRouter (for testing) + */ +export function resetProxyRouter(): void { + routerInstance = null; +} diff --git a/src/core/state-manager.ts b/src/core/state-manager.ts new file mode 100644 index 0000000..e22f2b4 --- /dev/null +++ b/src/core/state-manager.ts @@ -0,0 +1,192 @@ +import YAML from "yaml"; +import { StateError } from "../types/errors.ts"; +import { + type State, + type TargetState, + type ToolState, + createEmptyState, + parseState, +} from "../types/state.ts"; +import { ensureParentDir, getStatePath, resolvePath } from "../utils/paths.ts"; + +/** + * Manages system state with atomic writes + */ +export class StateManager { + private statePath: string; + private state: State | null = null; + + constructor(statePath?: string) { + this.statePath = resolvePath(statePath ?? getStatePath()); + } + + /** + * Load state from disk + * Creates empty state if not exists + */ + async load(): Promise { + const file = Bun.file(this.statePath); + const exists = await file.exists(); + + if (!exists) { + // Create empty state + const emptyState = createEmptyState(); + await this.save(emptyState); + this.state = emptyState; + return this.state; + } + + try { + const content = await file.text(); + const data = YAML.parse(content); + this.state = parseState(data); + return this.state; + } catch (error) { + if (error instanceof Error) { + throw new StateError(`Failed to load state: ${error.message}`); + } + throw new StateError("Failed to load state: Unknown error"); + } + } + + /** + * Save state to disk atomically + * Uses temp file + rename pattern to prevent corruption + */ + async save(state: State): Promise { + try { + await ensureParentDir(this.statePath); + + // Update timestamp + state.last_updated = new Date().toISOString(); + + const content = YAML.stringify(state); + const tempPath = `${this.statePath}.tmp`; + + // Write to temp file first + await Bun.write(tempPath, content); + + // Atomic rename + await Bun.spawn(["mv", tempPath, this.statePath]).exited; + + this.state = state; + } catch (error) { + if (error instanceof Error) { + throw new StateError(`Failed to save state: ${error.message}`); + } + throw new StateError("Failed to save state: Unknown error"); + } + } + + /** + * Get current state + * Always reloads from disk to see changes from other processes (CLI + proxy) + */ + async get(): Promise { + return this.load(); + } + + /** + * Update state with a modifier function + * Handles get -> modify -> save pattern + */ + async update(fn: (state: State) => State | Promise): Promise { + const currentState = await this.get(); + const newState = await fn(currentState); + await this.save(newState); + } + + /** + * Check if system is locked + */ + async isLocked(): Promise { + const state = await this.get(); + return state.locked; + } + + /** + * Set lock status + */ + async setLocked(locked: boolean): Promise { + await this.update((state) => ({ + ...state, + locked, + })); + } + + /** + * Add installed target to state + */ + async addTarget(target: TargetState): Promise { + await this.update((state) => ({ + ...state, + targets: [...state.targets, target], + })); + } + + /** + * Remove target from state + */ + async removeTarget(name: string): Promise { + await this.update((state) => ({ + ...state, + targets: state.targets.filter((t) => t.name !== name), + })); + } + + /** + * Find target in state by name + */ + async findTarget(name: string): Promise { + const state = await this.get(); + return state.targets.find((t) => t.name === name); + } + + /** + * Add installed tool to state + */ + async addTool(tool: ToolState): Promise { + await this.update((state) => ({ + ...state, + tools: [...state.tools, tool], + })); + } + + /** + * Remove tool from state + */ + async removeTool(name: string): Promise { + await this.update((state) => ({ + ...state, + tools: state.tools.filter((t) => t.name !== name), + })); + } + + /** + * Find tool in state by name + */ + async findTool(name: string): Promise { + const state = await this.get(); + return state.tools.find((t) => t.name === name); + } + + /** + * Get the state file path + */ + getPath(): string { + return this.statePath; + } +} + +// Default singleton instance +let defaultInstance: StateManager | null = null; + +/** + * Get the default StateManager instance + */ +export function getStateManager(): StateManager { + if (defaultInstance === null) { + defaultInstance = new StateManager(); + } + return defaultInstance; +} diff --git a/src/core/tool-executor.ts b/src/core/tool-executor.ts new file mode 100644 index 0000000..de4f741 --- /dev/null +++ b/src/core/tool-executor.ts @@ -0,0 +1,147 @@ +import { join } from "node:path"; +import { ModuleError } from "../types/errors.ts"; +import type { ToolModule } from "../types/module.ts"; +import { logger } from "../utils/logger.ts"; + +/** + * Result of a tool installation + */ +export interface InstallResult { + version?: string; +} + +/** + * Manages tool script execution (install/remove) + */ +export class ToolExecutor { + /** + * Execute the install script for a tool module + */ + async executeInstall(module: ToolModule): Promise { + if (!module.path) { + throw new ModuleError("Module path not set", module.name); + } + + const scriptPath = join(module.path, module.install); + + // Check if script exists and is executable + await this.validateScript(scriptPath, "install"); + + logger.info(`Running install script: ${scriptPath}`); + + // Execute the script + const output = await this.executeScript(scriptPath, module.install_requires_root); + + // Parse version from output (looking for TOOL_VERSION=xxx pattern) + const version = this.parseVersion(output); + + return { version }; + } + + /** + * Execute the remove script for a tool module + */ + async executeRemove(module: ToolModule): Promise { + if (!module.path) { + throw new ModuleError("Module path not set", module.name); + } + + const scriptPath = join(module.path, module.remove); + + // Check if script exists and is executable + await this.validateScript(scriptPath, "remove"); + + logger.info(`Running remove script: ${scriptPath}`); + + // Execute the script + await this.executeScript(scriptPath, module.install_requires_root); + } + + /** + * Validate that a script exists and is executable + */ + private async validateScript(scriptPath: string, scriptType: string): Promise { + const file = Bun.file(scriptPath); + + if (!(await file.exists())) { + throw new ModuleError(`${scriptType} script not found: ${scriptPath}`); + } + + // Check if file is executable (Unix permissions) + // Note: Bun.file doesn't expose permissions, so we'll rely on the shell execution to fail if not executable + } + + /** + * Execute a script with appropriate permissions + */ + private async executeScript(scriptPath: string, requiresRoot: boolean): Promise { + const command = requiresRoot ? ["sudo", "bash", scriptPath] : ["bash", scriptPath]; + + const proc = Bun.spawn(command, { + stdout: "pipe", + stderr: "pipe", + }); + + // Capture output + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + // Log stderr if present (warnings, informational messages) + if (stderr.trim()) { + // Split by lines and log each + for (const line of stderr.trim().split("\n")) { + logger.info(` ${line}`); + } + } + + // Log stdout (install progress messages) + if (stdout.trim()) { + for (const line of stdout.trim().split("\n")) { + // Skip version output lines (we parse those separately) + if (!line.startsWith("TOOL_VERSION=")) { + logger.info(` ${line}`); + } + } + } + + // Check exit code + if (exitCode !== 0) { + throw new ModuleError( + `Script execution failed with exit code ${exitCode}\nStderr: ${stderr}\nStdout: ${stdout}`, + ); + } + + return stdout; + } + + /** + * Parse version from script output + * Looks for lines like: TOOL_VERSION=v2.1.0 + */ + private parseVersion(output: string): string | undefined { + const lines = output.split("\n"); + for (const line of lines) { + const match = line.match(/^TOOL_VERSION=(.+)$/); + if (match?.[1]) { + return match[1].trim(); + } + } + return undefined; + } +} + +/** + * Singleton instance + */ +let toolExecutorInstance: ToolExecutor | null = null; + +/** + * Get the ToolExecutor singleton + */ +export function getToolExecutor(): ToolExecutor { + if (!toolExecutorInstance) { + toolExecutorInstance = new ToolExecutor(); + } + return toolExecutorInstance; +} diff --git a/src/platform/index.ts b/src/platform/index.ts new file mode 100644 index 0000000..e47e319 --- /dev/null +++ b/src/platform/index.ts @@ -0,0 +1,66 @@ +import { KatanaError } from "../types/errors.ts"; +import { type DnsManager, getDnsManager as getLinuxDnsManager } from "./linux/dns-manager.ts"; + +/** + * Supported platforms + */ +export type Platform = "linux"; + +/** + * Error thrown when running on an unsupported platform + */ +export class UnsupportedPlatformError extends KatanaError { + constructor(platform: string) { + super(`Unsupported platform: ${platform}`, "UNSUPPORTED_PLATFORM"); + this.name = "UnsupportedPlatformError"; + } + + override help() { + return "Katana currently only supports Linux"; + } +} + +/** + * Get the current platform + * @throws UnsupportedPlatformError if platform is not supported + */ +export function getPlatform(): Platform { + const platform = process.platform; + + if (platform === "linux") { + return "linux"; + } + + throw new UnsupportedPlatformError(platform); +} + +/** + * Check if the current platform is supported + */ +export function isPlatformSupported(): boolean { + try { + getPlatform(); + return true; + } catch { + return false; + } +} + +/** + * Get the DNS manager for the current platform + */ +export function getDnsManager(): DnsManager { + const platform = getPlatform(); + + switch (platform) { + case "linux": + return getLinuxDnsManager(); + default: + // This should never happen due to getPlatform() throwing + throw new UnsupportedPlatformError(platform); + } +} + +// Re-export types +export type { HostsEntry, DnsSyncResult, IDnsManager } from "./types.ts"; +export { DnsManager } from "./linux/dns-manager.ts"; diff --git a/src/platform/linux/dns-manager.ts b/src/platform/linux/dns-manager.ts new file mode 100644 index 0000000..fe90c5d --- /dev/null +++ b/src/platform/linux/dns-manager.ts @@ -0,0 +1,287 @@ +import { DNSError, DNSPermissionError } from "../../types/errors.ts"; +import type { DnsSyncResult, HostsEntry, IDnsManager } from "../types.ts"; + +/** + * Marker comment used to identify Katana-managed entries + */ +const MARKER = "# katana-managed"; + +/** + * Manages /etc/hosts entries on Linux + */ +export class DnsManager implements IDnsManager { + private hostsPath: string; + + constructor(hostsPath = "/etc/hosts") { + this.hostsPath = hostsPath; + } + + /** + * Read all entries from /etc/hosts + */ + async read(): Promise { + try { + const file = Bun.file(this.hostsPath); + const content = await file.text(); + return this.parseHostsFile(content); + } catch (error) { + if (error instanceof Error && error.message.includes("ENOENT")) { + return []; + } + throw new DNSError(`Failed to read hosts file: ${error}`); + } + } + + /** + * Add a single entry to the hosts file + */ + async addEntry(hostname: string, ip = "127.0.0.1"): Promise { + const entries = await this.read(); + + // Check if entry already exists + const existing = entries.find((e) => e.hostname === hostname && e.managed); + if (existing) { + return; // Already exists, nothing to do + } + + // Read current content and append + const file = Bun.file(this.hostsPath); + let content = await file.text(); + + // Ensure file ends with newline + if (!content.endsWith("\n")) { + content += "\n"; + } + + // Add new entry + content += `${ip} ${hostname} ${MARKER}\n`; + + await this.writeHostsFile(content); + } + + /** + * Remove a single entry from the hosts file + */ + async removeEntry(hostname: string): Promise { + const file = Bun.file(this.hostsPath); + const content = await file.text(); + const lines = content.split("\n"); + + const filteredLines = lines.filter((line) => { + // Only remove Katana-managed entries matching the hostname + if (!line.includes(MARKER)) { + return true; + } + const parsed = this.parseLine(line); + return parsed === null || parsed.hostname !== hostname; + }); + + await this.writeHostsFile(filteredLines.join("\n")); + } + + /** + * Sync entries to match the target list + * Adds missing entries, removes stale entries, preserves non-Katana entries + */ + async sync(hostnames: string[], ip = "127.0.0.1"): Promise { + const result: DnsSyncResult = { + added: [], + removed: [], + unchanged: [], + }; + + const file = Bun.file(this.hostsPath); + const content = await file.text(); + const lines = content.split("\n"); + const targetSet = new Set(hostnames); + + // Track which managed hostnames we've seen + const existingManaged = new Map(); // hostname -> line index + + // First pass: identify existing managed entries + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line?.includes(MARKER)) { + const parsed = this.parseLine(line); + if (parsed?.managed) { + existingManaged.set(parsed.hostname, i); + } + } + } + + // Build new content + const newLines: string[] = []; + + // Keep non-managed lines and managed lines that should stay + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + + if (!line.includes(MARKER)) { + // Not managed by Katana - preserve exactly + newLines.push(line); + } else { + const parsed = this.parseLine(line); + if (parsed && targetSet.has(parsed.hostname)) { + // Should keep this entry + newLines.push(line); + result.unchanged.push(parsed.hostname); + } else if (parsed) { + // Should remove this entry + result.removed.push(parsed.hostname); + // Don't add to newLines + } + } + } + + // Add missing entries + for (const hostname of hostnames) { + if (!existingManaged.has(hostname)) { + // Ensure we end with a newline before adding + const lastLine = newLines[newLines.length - 1]; + if (lastLine !== "" && newLines.length > 0) { + // Content doesn't end with empty line, we'll add our entry after + } + newLines.push(`${ip} ${hostname} ${MARKER}`); + result.added.push(hostname); + } + } + + // Ensure file ends cleanly (single trailing newline) + let finalContent = newLines.join("\n"); + if (!finalContent.endsWith("\n")) { + finalContent += "\n"; + } + // Remove multiple trailing newlines + finalContent = finalContent.replace(/\n+$/, "\n"); + + await this.writeHostsFile(finalContent); + + return result; + } + + /** + * List only Katana-managed entries + */ + async listManaged(): Promise { + const entries = await this.read(); + return entries.filter((e) => e.managed); + } + + /** + * Get the path to the hosts file + */ + getPath(): string { + return this.hostsPath; + } + + /** + * Parse the hosts file content into entries + */ + private parseHostsFile(content: string): HostsEntry[] { + const entries: HostsEntry[] = []; + const lines = content.split("\n"); + + for (const line of lines) { + const parsed = this.parseLine(line); + if (parsed) { + entries.push(parsed); + } + } + + return entries; + } + + /** + * Parse a single line from the hosts file + * Returns null for comments, empty lines, or invalid lines + */ + private parseLine(line: string): HostsEntry | null { + const trimmed = line.trim(); + + // Skip empty lines + if (!trimmed) { + return null; + } + + // Check if this is a pure comment line (not a managed entry) + if (trimmed.startsWith("#") && !trimmed.includes(MARKER)) { + return null; + } + + // Check if this is a Katana-managed entry + const managed = line.includes(MARKER); + + // Remove the marker comment for parsing + const lineWithoutMarker = line.replace(MARKER, "").trim(); + + // Remove any other inline comments + const lineWithoutComments = (lineWithoutMarker.split("#")[0] ?? "").trim(); + + if (!lineWithoutComments) { + return null; + } + + // Split on whitespace + const parts = lineWithoutComments.split(/\s+/); + + if (parts.length < 2) { + return null; + } + + const [ip, hostname] = parts; + + // Basic validation + if (!ip || !hostname) { + return null; + } + + return { ip, hostname, managed }; + } + + /** + * Write content to the hosts file + * Requires sudo for /etc/hosts + */ + private async writeHostsFile(content: string): Promise { + try { + // Use sudo tee to write to /etc/hosts + const proc = Bun.spawn(["sudo", "tee", this.hostsPath], { + stdin: new Blob([content]), + stdout: "pipe", // Suppress tee's stdout (it echoes input) + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + if (stderr.includes("permission denied") || stderr.includes("sudo")) { + throw new DNSPermissionError(); + } + throw new DNSError(`Failed to write hosts file: ${stderr}`); + } + } catch (error) { + if (error instanceof DNSError) { + throw error; + } + if (error instanceof Error && error.message.includes("sudo")) { + throw new DNSPermissionError(); + } + throw new DNSError(`Failed to write hosts file: ${error}`); + } + } +} + +// Default singleton instance +let defaultInstance: DnsManager | null = null; + +/** + * Get the default DnsManager instance + */ +export function getDnsManager(): DnsManager { + if (defaultInstance === null) { + defaultInstance = new DnsManager(); + } + return defaultInstance; +} diff --git a/src/platform/types.ts b/src/platform/types.ts new file mode 100644 index 0000000..145c013 --- /dev/null +++ b/src/platform/types.ts @@ -0,0 +1,73 @@ +/** + * Platform abstraction types + * Defines interfaces for platform-specific operations + */ + +/** + * A single entry from /etc/hosts + */ +export interface HostsEntry { + /** IP address */ + ip: string; + + /** Hostname */ + hostname: string; + + /** Whether this entry is managed by Katana */ + managed: boolean; +} + +/** + * Result of a DNS sync operation + */ +export interface DnsSyncResult { + /** Hostnames that were added */ + added: string[]; + + /** Hostnames that were removed */ + removed: string[]; + + /** Hostnames that were already present */ + unchanged: string[]; +} + +/** + * Interface for DNS management operations + */ +export interface IDnsManager { + /** + * Read all entries from the hosts file + */ + read(): Promise; + + /** + * Add a single entry to the hosts file + * @param hostname The hostname to add + * @param ip The IP address (default: 127.0.0.1) + */ + addEntry(hostname: string, ip?: string): Promise; + + /** + * Remove a single entry from the hosts file + * @param hostname The hostname to remove + */ + removeEntry(hostname: string): Promise; + + /** + * Sync entries to match the target list + * Adds missing entries, removes stale entries, preserves non-Katana entries + * @param hostnames List of hostnames that should exist + * @param ip The IP address for all entries (default: 127.0.0.1) + */ + sync(hostnames: string[], ip?: string): Promise; + + /** + * List only Katana-managed entries + */ + listManaged(): Promise; + + /** + * Get the path to the hosts file + */ + getPath(): string; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c644e30 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,590 @@ +import type { Server, ServerWebSocket } from "bun"; +import { getCertManager } from "./core/cert-manager.ts"; +import { getConfigManager } from "./core/config-manager.ts"; +import { getDockerClient } from "./core/docker-client.ts"; +import { type ResolvedRoute, getProxyRouter } from "./core/proxy-router.ts"; +import { handleApiRequest } from "./server/routes/index.ts"; +import { getBindAddress } from "./types/config.ts"; +import { CertNotInitializedError, PortBindError } from "./types/errors.ts"; +import { logger } from "./utils/logger.ts"; + +// Import embedded assets (generated by build.ts) +// Using dynamic import to handle case where assets haven't been built yet +let embeddedAssets: Record = {}; +try { + const assets = await import("./ui/embedded-assets.ts"); + embeddedAssets = assets.embeddedAssets; +} catch { + // Assets not built yet - will serve placeholder +} + +/** + * WebSocket proxy state attached to each client connection + */ +interface WebSocketProxyState { + upstream: WebSocket | null; + targetHost: string; + targetPort: number; +} + +let httpsServer: Server | null = null; +let httpServer: Server | null = null; + +/** + * Start the proxy server + * Runs in foreground - use Ctrl+C or SIGTERM to stop + */ +export async function startProxyServer(): Promise { + const configManager = getConfigManager(); + const certManager = getCertManager(); + const router = await getProxyRouter(); + + const config = await configManager.get(); + const bindAddress = getBindAddress(config); + + // Verify certificates exist + const certsValid = await certManager.validateCerts(); + if (!certsValid) { + throw new CertNotInitializedError(); + } + + const tlsOptions = await certManager.getTLSOptions(); + + // Start HTTPS server + httpsServer = startHttpsServer({ + port: config.proxy.https_port, + hostname: bindAddress, + tls: tlsOptions, + dockerNetwork: config.docker_network, + }); + + // Start HTTP redirect server + httpServer = startHttpRedirectServer({ + httpPort: config.proxy.http_port, + httpsPort: config.proxy.https_port, + hostname: bindAddress, + }); + + logger.info("Proxy listening on:"); + logger.info(` HTTPS: https://${bindAddress}:${config.proxy.https_port}`); + logger.info(` HTTP: http://${bindAddress}:${config.proxy.http_port} (redirects to HTTPS)`); + logger.info(` Dashboard: https://${router.getDashboardHostname()}`); + + // Log registered routes + const routes = router.getRoutes(); + if (routes.size > 0) { + logger.info(""); + logger.info("Registered routes:"); + for (const [hostname, route] of routes) { + logger.info(` https://${hostname} -> ${route.containerName}:${route.port}`); + } + } else { + logger.info(""); + logger.warn("No target routes configured. Install a target with: katana install "); + } + + logger.info(""); + logger.info("Press Ctrl+C to stop the proxy"); + + // Handle graceful shutdown + const shutdown = () => { + logger.info(""); + logger.info("Shutting down proxy..."); + httpsServer?.stop(); + httpServer?.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +interface HttpsServerOptions { + port: number; + hostname: string; + tls: { cert: string; key: string; ca?: string }; + dockerNetwork: string; +} + +function startHttpsServer(options: HttpsServerOptions): Server { + const { port, hostname, tls, dockerNetwork } = options; + + try { + return Bun.serve({ + port, + hostname, + tls: { + cert: tls.cert, + key: tls.key, + // Note: Don't pass `ca` here - it enables client certificate verification + }, + + async fetch(req, server) { + const hostname = req.headers.get("host") || ""; + const router = await getProxyRouter(); + const routeResult = router.resolve(hostname); + + switch (routeResult.type) { + case "dashboard": + return handleDashboard(req); + + case "target": { + // Check for WebSocket upgrade + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return handleWebSocketUpgrade(req, server, routeResult.route, dockerNetwork); + } + return proxyHttpRequest(req, routeResult.route, dockerNetwork); + } + + case "not_found": + return new Response( + htmlPage( + "404 - Target Not Found", + `

No target configured for hostname: ${escapeHtml(hostname)}

+

Available commands:

+
    +
  • katana list - List available targets
  • +
  • katana install <target> - Install a target
  • +
`, + ), + { + status: 404, + headers: { "Content-Type": "text/html" }, + }, + ); + } + }, + + websocket: { + message(ws: ServerWebSocket, message) { + // Forward message to upstream + const state = ws.data; + if (state.upstream?.readyState === WebSocket.OPEN) { + state.upstream.send(message); + } + }, + + close(ws: ServerWebSocket) { + // Close upstream connection + const state = ws.data; + state.upstream?.close(); + }, + + open(_ws: ServerWebSocket) { + // Connection opened - upstream setup happens in upgrade handler + }, + }, + + error(error) { + logger.error("Server error:", error); + return new Response("Internal Server Error", { status: 500 }); + }, + }); + } catch (error: unknown) { + const err = error as { code?: string }; + if (err.code === "EACCES") { + throw new PortBindError(port, "Permission denied"); + } + if (err.code === "EADDRINUSE") { + throw new PortBindError(port, "Port already in use"); + } + throw error; + } +} + +/** + * Handle HTTP-to-HTTPS redirect server + */ +function startHttpRedirectServer(options: { + httpPort: number; + httpsPort: number; + hostname: string; +}): Server { + try { + return Bun.serve({ + port: options.httpPort, + hostname: options.hostname, + + fetch(req) { + const url = new URL(req.url); + const httpsUrl = `https://${url.hostname}${options.httpsPort === 443 ? "" : `:${options.httpsPort}`}${url.pathname}${url.search}`; + + return Response.redirect(httpsUrl, 301); + }, + + error() { + return new Response("Redirect to HTTPS", { status: 500 }); + }, + }); + } catch (error: unknown) { + const err = error as { code?: string }; + if (err.code === "EACCES") { + throw new PortBindError(options.httpPort, "Permission denied"); + } + if (err.code === "EADDRINUSE") { + throw new PortBindError(options.httpPort, "Port already in use"); + } + throw error; + } +} + +/** + * Handle dashboard requests + * Routes: API endpoints, static files, or SPA fallback + */ +async function handleDashboard(req: Request): Promise { + const url = new URL(req.url); + const pathname = url.pathname; + + // API routes + if (pathname.startsWith("/api/")) { + const response = await handleApiRequest(req); + if (response) { + return response; + } + // Unknown API route + return Response.json({ success: false, error: "Not found" }, { status: 404 }); + } + + // Static files (js, css, images) + if (pathname.match(/\.(js|css|ico|png|svg|woff2?)$/)) { + return serveStaticFile(pathname); + } + + // SPA fallback - serve index.html for all other routes + return serveIndexHtml(); +} + +/** + * Serve static files from embedded assets + */ +function serveStaticFile(pathname: string): Response { + // Determine content type + const ext = pathname.split(".").pop()?.toLowerCase(); + const contentTypes: Record = { + js: "application/javascript", + css: "text/css", + ico: "image/x-icon", + png: "image/png", + svg: "image/svg+xml", + woff: "font/woff", + woff2: "font/woff2", + }; + const contentType = contentTypes[ext ?? ""] ?? "application/octet-stream"; + + // Get filename without leading slash + const filename = pathname.replace(/^\//, ""); + + // Check embedded assets first (works in compiled binary) + if (embeddedAssets[filename]) { + return new Response(embeddedAssets[filename], { + headers: { "Content-Type": contentType }, + }); + } + + // 404 if not found + return new Response("Not Found", { status: 404 }); +} + +/** + * Serve the main index.html page + */ +function serveIndexHtml(): Response { + // Check embedded assets first (works in compiled binary) + if (embeddedAssets["index.html"]) { + return new Response(embeddedAssets["index.html"], { + headers: { "Content-Type": "text/html" }, + }); + } + + // Serve placeholder if UI not built + return new Response(getPlaceholderHtml(), { + headers: { "Content-Type": "text/html" }, + }); +} + +/** + * Placeholder HTML shown when UI is not built + */ +function getPlaceholderHtml(): string { + return ` + + + Katana Dashboard + + + +

Katana Dashboard

+
+

The web dashboard UI has not been built yet.

+

Build the UI with:

+
bun run build:ui
+

Or use the CLI to manage targets:

+
    +
  • katana status - View system status
  • +
  • katana list - List available targets
  • +
  • katana install <target> - Install a target
  • +
+
+

+ Katana is the lab management solution for OWASP SamuraiWTF. +

+ +`; +} + +/** + * Proxy an HTTP request to a container + */ +async function proxyHttpRequest( + req: Request, + route: ResolvedRoute, + networkName: string, +): Promise { + const docker = getDockerClient(); + + // Get container IP + const containerIP = await docker.getContainerIPOnNetwork(route.containerName, networkName); + + if (!containerIP) { + // Check if container is running + const isRunning = await docker.isContainerRunning(route.containerName); + if (!isRunning) { + return new Response( + htmlPage( + "503 - Target Not Running", + `

Container ${escapeHtml(route.containerName)} is not running.

+

Start it with:

+
katana start ${escapeHtml(route.targetName)}
`, + ), + { + status: 503, + headers: { "Content-Type": "text/html" }, + }, + ); + } + + return new Response( + htmlPage( + "502 - Bad Gateway", + `

Container ${escapeHtml(route.containerName)} is not reachable on network ${escapeHtml(networkName)}.

+

Try restarting the target:

+
katana stop ${escapeHtml(route.targetName)} && katana start ${escapeHtml(route.targetName)}
`, + ), + { + status: 502, + headers: { "Content-Type": "text/html" }, + }, + ); + } + + // Build target URL + const originalUrl = new URL(req.url); + const targetUrl = `http://${containerIP}:${route.port}${originalUrl.pathname}${originalUrl.search}`; + + // Clone headers, removing hop-by-hop headers + const headers = new Headers(req.headers); + headers.delete("host"); + headers.set("host", `${containerIP}:${route.port}`); + headers.delete("connection"); + headers.delete("keep-alive"); + headers.delete("transfer-encoding"); + headers.delete("te"); + headers.delete("trailer"); + headers.delete("upgrade"); + + // Add X-Forwarded headers + headers.set("x-forwarded-for", req.headers.get("x-real-ip") || "127.0.0.1"); + headers.set("x-forwarded-proto", "https"); + headers.set("x-forwarded-host", req.headers.get("host") || ""); + + try { + // Forward request to container + // Use decompress: false to pass through compressed responses transparently + // This is important for security testing - we want minimal modification of traffic + const response = await fetch(targetUrl, { + method: req.method, + headers, + body: req.body, + redirect: "manual", // Don't follow redirects - return them to client + decompress: false, // Don't auto-decompress - pass through as-is + }); + + // Clone response headers + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("transfer-encoding"); // Bun handles this + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error(`Proxy error for ${route.containerName}:`, err.message); + + return new Response( + htmlPage( + "502 - Bad Gateway", + `

Could not connect to ${escapeHtml(route.containerName)}

+

Error: ${escapeHtml(err.message)}

`, + ), + { + status: 502, + headers: { "Content-Type": "text/html" }, + }, + ); + } +} + +/** + * Handle WebSocket upgrade and proxy + */ +async function handleWebSocketUpgrade( + req: Request, + server: Server, + route: ResolvedRoute, + networkName: string, +): Promise { + const docker = getDockerClient(); + + // Get container IP + const containerIP = await docker.getContainerIPOnNetwork(route.containerName, networkName); + + if (!containerIP) { + return new Response("Target not available", { status: 503 }); + } + + const originalUrl = new URL(req.url); + const wsUrl = `ws://${containerIP}:${route.port}${originalUrl.pathname}${originalUrl.search}`; + + // Create state for this connection + const state: WebSocketProxyState = { + upstream: null, + targetHost: containerIP, + targetPort: route.port, + }; + + // Upgrade the client connection + const upgraded = server.upgrade(req, { + data: state, + }); + + if (!upgraded) { + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // Connect to upstream WebSocket + // Note: This is a simplified implementation. For production, + // we'd need to handle the upstream connection more carefully + // and properly bridge the two connections. + try { + const upstream = new WebSocket(wsUrl); + + upstream.onopen = () => { + state.upstream = upstream; + }; + + upstream.onmessage = (event) => { + // We need to forward to client, but we don't have a reference + // to the client WebSocket here. This is a limitation of this approach. + // A more robust solution would be to store connections in a map. + }; + + upstream.onclose = () => { + state.upstream = null; + }; + + upstream.onerror = (error) => { + logger.error("WebSocket upstream error:", error); + }; + } catch (error) { + logger.error("Failed to connect to upstream WebSocket:", error); + } + + return undefined; // Bun handles the upgrade +} + +/** + * Generate HTML page wrapper + */ +function htmlPage(title: string, content: string): string { + return ` + + + + ${escapeHtml(title)} - Katana + + + +

${escapeHtml(title)}

+ ${content} + + +`; +} + +/** + * Escape HTML entities + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/server/routes/certs.ts b/src/server/routes/certs.ts new file mode 100644 index 0000000..02f8161 --- /dev/null +++ b/src/server/routes/certs.ts @@ -0,0 +1,47 @@ +/** + * API routes for certificate management + */ + +import { getCertManager } from "../../core/cert-manager.ts"; + +// ============================================================================= +// Route Handlers +// ============================================================================= + +/** + * GET /api/certs/ca + * Download the CA certificate for browser import + */ +export async function handleGetCA(_req: Request): Promise { + try { + const certManager = getCertManager(); + const caCertPath = certManager.getCACertPath(); + + // Check if CA exists + const caFile = Bun.file(caCertPath); + if (!(await caFile.exists())) { + return Response.json( + { + success: false, + error: "CA certificate not initialized. Run 'katana cert init' first.", + }, + { status: 404 }, + ); + } + + // Read the CA certificate + const caContent = await caFile.text(); + + // Return as downloadable file + return new Response(caContent, { + headers: { + "Content-Type": "application/x-x509-ca-cert", + "Content-Disposition": 'attachment; filename="katana-ca.crt"', + "Cache-Control": "no-cache", + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Response.json({ success: false, error: message }, { status: 500 }); + } +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts new file mode 100644 index 0000000..7a9e4b1 --- /dev/null +++ b/src/server/routes/index.ts @@ -0,0 +1,78 @@ +/** + * API route dispatcher + */ + +import { handleGetCA } from "./certs.ts"; +import { handleGetModules, handleModuleOperation } from "./modules.ts"; +import { handleGetOperation, handleOperationStream } from "./operations.ts"; +import { handleGetSystem, handleLock, handleUnlock } from "./system.ts"; + +/** + * Handle API requests + * Returns null if not an API route + */ +export async function handleApiRequest(req: Request): Promise { + const url = new URL(req.url); + const pathname = url.pathname; + const method = req.method; + + // Module routes + // GET /api/modules + if (pathname === "/api/modules" && method === "GET") { + return handleGetModules(req); + } + + // POST /api/modules/:name/:operation + const moduleOpMatch = pathname.match(/^\/api\/modules\/([^/]+)\/(install|remove|start|stop)$/); + if (moduleOpMatch && method === "POST") { + const name = moduleOpMatch[1]; + const operation = moduleOpMatch[2]; + if (name && operation) { + return handleModuleOperation(req, name, operation); + } + } + + // Operation routes + // GET /api/operations/:id + const opStatusMatch = pathname.match(/^\/api\/operations\/([^/]+)$/); + if (opStatusMatch && method === "GET") { + const operationId = opStatusMatch[1]; + if (operationId) { + return handleGetOperation(req, operationId); + } + } + + // GET /api/operations/:id/stream + const opStreamMatch = pathname.match(/^\/api\/operations\/([^/]+)\/stream$/); + if (opStreamMatch && method === "GET") { + const operationId = opStreamMatch[1]; + if (operationId) { + return handleOperationStream(req, operationId); + } + } + + // System routes + // GET /api/system + if (pathname === "/api/system" && method === "GET") { + return handleGetSystem(req); + } + + // POST /api/system/lock + if (pathname === "/api/system/lock" && method === "POST") { + return handleLock(req); + } + + // POST /api/system/unlock + if (pathname === "/api/system/unlock" && method === "POST") { + return handleUnlock(req); + } + + // Certificate routes + // GET /api/certs/ca + if (pathname === "/api/certs/ca" && method === "GET") { + return handleGetCA(req); + } + + // Not an API route we recognize + return null; +} diff --git a/src/server/routes/modules.ts b/src/server/routes/modules.ts new file mode 100644 index 0000000..1f43f9b --- /dev/null +++ b/src/server/routes/modules.ts @@ -0,0 +1,237 @@ +/** + * API routes for module management + */ + +import { getComposeManager } from "../../core/compose-manager.ts"; +import { getConfigManager } from "../../core/config-manager.ts"; +import { getModuleLoader } from "../../core/module-loader.ts"; +import { getOperationManager } from "../../core/operation-manager.ts"; +import { getStateManager } from "../../core/state-manager.ts"; +import { getTargetHostname } from "../../types/config.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +export type ModuleStatus = "not_installed" | "installed" | "running" | "stopped" | "unknown"; + +export interface ModuleInfo { + name: string; + category: "targets" | "tools"; + description: string; + status: ModuleStatus; + hrefs: string[]; +} + +interface ModulesResponse { + success: true; + data: { + modules: ModuleInfo[]; + locked: boolean; + lockMessage?: string; + }; +} + +interface OperationResponse { + success: true; + data: { + operationId: string; + }; +} + +interface ErrorResponse { + success: false; + error: string; +} + +// ============================================================================= +// Route Handlers +// ============================================================================= + +/** + * GET /api/modules + * List all available modules with their status + */ +export async function handleGetModules(req: Request): Promise { + try { + const url = new URL(req.url); + const categoryFilter = url.searchParams.get("category") as "targets" | "tools" | null; + + const moduleLoader = await getModuleLoader(); + const stateManager = getStateManager(); + const composeManager = await getComposeManager(); + const configManager = getConfigManager(); + const config = await configManager.get(); + + // Load all modules + let modules = await moduleLoader.loadAll(); + + // Filter by category if requested + if (categoryFilter) { + modules = modules.filter((m) => m.category === categoryFilter); + } + + // Get installed targets from state + const state = await stateManager.get(); + const installedTargets = new Set(state.targets.map((t) => t.name.toLowerCase())); + const installedTools = new Set(state.tools.map((t) => t.name.toLowerCase())); + + // Build module info with status + const moduleInfos: ModuleInfo[] = []; + + for (const mod of modules) { + const isInstalled = + mod.category === "targets" + ? installedTargets.has(mod.name.toLowerCase()) + : installedTools.has(mod.name.toLowerCase()); + + let status: ModuleStatus = "not_installed"; + const hrefs: string[] = []; + + if (mod.category === "targets" && isInstalled) { + // Check compose status for running state + const composeStatus = await composeManager.status(mod.name); + + if (composeStatus.containers.length === 0) { + status = "installed"; + } else if (composeStatus.all_running) { + status = "running"; + + // Build URLs from proxy config + for (const p of mod.proxy) { + const hostname = getTargetHostname(config, p.hostname); + hrefs.push(`https://${hostname}/`); + } + } else if (composeStatus.any_running) { + status = "running"; // Partially running is still running + } else { + status = "stopped"; + } + } else if (mod.category === "tools" && isInstalled) { + status = "installed"; + } + + moduleInfos.push({ + name: mod.name, + category: mod.category, + description: mod.description, + status, + hrefs, + }); + } + + const response: ModulesResponse = { + success: true, + data: { + modules: moduleInfos, + locked: state.locked, + lockMessage: state.locked ? "System is locked. Unlock to make changes." : undefined, + }, + }; + + return Response.json(response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const response: ErrorResponse = { success: false, error: message }; + return Response.json(response, { status: 500 }); + } +} + +/** + * POST /api/modules/:name/:operation + * Start an operation (install, remove, start, stop) + */ +export async function handleModuleOperation( + req: Request, + name: string, + operation: string, +): Promise { + try { + // Validate operation + const validOperations = ["install", "remove", "start", "stop"]; + if (!validOperations.includes(operation)) { + const response: ErrorResponse = { + success: false, + error: `Invalid operation: ${operation}. Must be one of: ${validOperations.join(", ")}`, + }; + return Response.json(response, { status: 400 }); + } + + const stateManager = getStateManager(); + const moduleLoader = await getModuleLoader(); + const operationManager = getOperationManager(); + + // Check system lock for install/remove + if (operation === "install" || operation === "remove") { + const locked = await stateManager.isLocked(); + if (locked) { + const response: ErrorResponse = { + success: false, + error: "System is locked. Run 'katana unlock' to allow changes.", + }; + return Response.json(response, { status: 423 }); // 423 Locked + } + } + + // Verify module exists + const module = await moduleLoader.findModule(name); + if (!module) { + const response: ErrorResponse = { + success: false, + error: `Module not found: ${name}`, + }; + return Response.json(response, { status: 404 }); + } + + // Check if operation already in progress + if (operationManager.hasOperationInProgress(name)) { + const response: ErrorResponse = { + success: false, + error: `Operation already in progress for module: ${name}`, + }; + return Response.json(response, { status: 409 }); // 409 Conflict + } + + // Validate operation is appropriate for current state + const state = await stateManager.get(); + const installedTarget = state.targets.find((t) => t.name.toLowerCase() === name.toLowerCase()); + + if (operation === "install" && installedTarget) { + const response: ErrorResponse = { + success: false, + error: `Module already installed: ${name}`, + }; + return Response.json(response, { status: 409 }); + } + + if ( + (operation === "remove" || operation === "start" || operation === "stop") && + !installedTarget + ) { + const response: ErrorResponse = { + success: false, + error: `Module not installed: ${name}`, + }; + return Response.json(response, { status: 400 }); + } + + // Create and start the operation + const tracked = await operationManager.createOperation( + name, + operation as "install" | "remove" | "start" | "stop", + ); + + const response: OperationResponse = { + success: true, + data: { + operationId: tracked.id, + }, + }; + + return Response.json(response, { status: 202 }); // 202 Accepted + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const response: ErrorResponse = { success: false, error: message }; + return Response.json(response, { status: 500 }); + } +} diff --git a/src/server/routes/operations.ts b/src/server/routes/operations.ts new file mode 100644 index 0000000..cee2a01 --- /dev/null +++ b/src/server/routes/operations.ts @@ -0,0 +1,68 @@ +/** + * API routes for operation streaming + */ + +import { getOperationManager } from "../../core/operation-manager.ts"; +import { createSSEHeaders, createSSEStream } from "../sse.ts"; + +// ============================================================================= +// Route Handlers +// ============================================================================= + +/** + * GET /api/operations/:id/stream + * SSE stream for operation progress + */ +export async function handleOperationStream(_req: Request, operationId: string): Promise { + const operationManager = getOperationManager(); + const operation = operationManager.getOperation(operationId); + + if (!operation) { + return Response.json( + { success: false, error: `Operation not found: ${operationId}` }, + { status: 404 }, + ); + } + + // Create SSE stream + const stream = createSSEStream((controller) => { + // Subscribe to operation events + const subscribed = operationManager.subscribe(operationId, controller); + if (!subscribed) { + controller.close(); + } + }); + + return new Response(stream, { + headers: createSSEHeaders(), + }); +} + +/** + * GET /api/operations/:id + * Get operation status (non-streaming) + */ +export async function handleGetOperation(_req: Request, operationId: string): Promise { + const operationManager = getOperationManager(); + const operation = operationManager.getOperation(operationId); + + if (!operation) { + return Response.json( + { success: false, error: `Operation not found: ${operationId}` }, + { status: 404 }, + ); + } + + return Response.json({ + success: true, + data: { + id: operation.id, + module: operation.module, + operation: operation.operation, + status: operation.status, + startedAt: operation.startedAt.toISOString(), + completedAt: operation.completedAt?.toISOString(), + error: operation.error, + }, + }); +} diff --git a/src/server/routes/system.ts b/src/server/routes/system.ts new file mode 100644 index 0000000..c0c4045 --- /dev/null +++ b/src/server/routes/system.ts @@ -0,0 +1,332 @@ +/** + * API routes for system status and management + */ + +import { getCertManager } from "../../core/cert-manager.ts"; +import { getConfigManager } from "../../core/config-manager.ts"; +import { getDockerClient } from "../../core/docker-client.ts"; +import { getProxyRouter } from "../../core/proxy-router.ts"; +import { getStateManager } from "../../core/state-manager.ts"; +import { getDnsManager } from "../../platform/index.ts"; +import { getBindAddress, getDashboardHostname } from "../../types/config.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +interface SystemStatusResponse { + success: true; + data: { + prerequisites: { + docker: { + installed: boolean; + version: string | null; + daemonRunning: boolean; + userCanConnect: boolean; + }; + }; + system: { + os: string; + kernel: string; + uptime: string; + memory: { + total: number; + used: number; + percentUsed: number; + }; + disk: { + path: string; + total: number; + used: number; + percentUsed: number; + }; + }; + katana: { + certs: { + valid: boolean; + expiresAt: string | null; + daysUntilExpiration: number | null; + }; + proxy: { + running: boolean; + routeCount: number; + bindAddress: string; + }; + dns: { + inSync: boolean; + managedCount: number; + expectedCount: number; + } | null; + }; + }; +} + +interface ErrorResponse { + success: false; + error: string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +async function getDockerVersion(): Promise { + try { + const proc = Bun.spawn(["docker", "--version"], { + stdout: "pipe", + stderr: "pipe", + }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + // Parse "Docker version 24.0.7, build afdd53b" + const match = output.match(/Docker version ([0-9.]+)/); + return match?.[1] ?? null; + } catch { + return null; + } +} + +async function getSystemInfo(): Promise<{ + os: string; + kernel: string; + uptime: string; + memory: { total: number; used: number; percentUsed: number }; + disk: { path: string; total: number; used: number; percentUsed: number }; +}> { + // Get OS info + const unameProc = Bun.spawn(["uname", "-s"], { stdout: "pipe" }); + const os = (await new Response(unameProc.stdout).text()).trim(); + await unameProc.exited; + + // Get kernel version + const kernelProc = Bun.spawn(["uname", "-r"], { stdout: "pipe" }); + const kernel = (await new Response(kernelProc.stdout).text()).trim(); + await kernelProc.exited; + + // Get uptime + let uptime = "unknown"; + try { + const uptimeProc = Bun.spawn(["uptime", "-p"], { stdout: "pipe" }); + uptime = (await new Response(uptimeProc.stdout).text()).trim().replace("up ", ""); + await uptimeProc.exited; + } catch { + // uptime -p not available on all systems + } + + // Get memory info (using free -b for bytes) + let memory = { total: 0, used: 0, percentUsed: 0 }; + try { + const memProc = Bun.spawn(["free", "-b"], { stdout: "pipe" }); + const memOutput = await new Response(memProc.stdout).text(); + await memProc.exited; + + // Parse: "Mem: 16000000 8000000 ..." + const memLine = memOutput.split("\n").find((l) => l.startsWith("Mem:")); + if (memLine) { + const parts = memLine.split(/\s+/).filter((p) => p); + const total = Number.parseInt(parts[1] || "0", 10); + const used = Number.parseInt(parts[2] || "0", 10); + memory = { + total, + used, + percentUsed: total > 0 ? Math.round((used / total) * 100) : 0, + }; + } + } catch { + // Ignore errors + } + + // Get disk info (for /) + let disk = { path: "/", total: 0, used: 0, percentUsed: 0 }; + try { + const dfProc = Bun.spawn(["df", "-B1", "/"], { stdout: "pipe" }); + const dfOutput = await new Response(dfProc.stdout).text(); + await dfProc.exited; + + // Parse: "Filesystem 1B-blocks Used Available Use% Mounted" + const lines = dfOutput.split("\n").filter((l) => l && !l.startsWith("Filesystem")); + if (lines[0]) { + const parts = lines[0].split(/\s+/).filter((p) => p); + disk = { + path: "/", + total: Number.parseInt(parts[1] || "0", 10), + used: Number.parseInt(parts[2] || "0", 10), + percentUsed: Number.parseInt((parts[4] || "0").replace("%", ""), 10), + }; + } + } catch { + // Ignore errors + } + + return { os, kernel, uptime, memory, disk }; +} + +// ============================================================================= +// Route Handlers +// ============================================================================= + +/** + * GET /api/system + * Get system status including Docker, certs, DNS + */ +export async function handleGetSystem(_req: Request): Promise { + try { + const docker = getDockerClient(); + const certManager = getCertManager(); + const configManager = getConfigManager(); + const stateManager = getStateManager(); + const config = await configManager.get(); + + // Docker status + const dockerVersion = await getDockerVersion(); + const daemonRunning = await docker.ping(); + let userCanConnect = false; + try { + await docker.checkPermissions(); + userCanConnect = true; + } catch { + userCanConnect = false; + } + + // System info + const systemInfo = await getSystemInfo(); + + // Certificate status + let certsValid = false; + let certsExpiresAt: string | null = null; + let daysUntilExpiration: number | null = null; + + try { + certsValid = await certManager.validateCerts(); + if (certsValid) { + daysUntilExpiration = await certManager.daysUntilExpiration(); + if (daysUntilExpiration !== null) { + const expiresDate = new Date(); + expiresDate.setDate(expiresDate.getDate() + daysUntilExpiration); + certsExpiresAt = expiresDate.toISOString(); + } + } + } catch { + // Certs not initialized + } + + // Proxy status (we're running if this endpoint is accessible) + const router = await getProxyRouter(); + const routes = router.getRoutes(); + const bindAddress = getBindAddress(config); + + // DNS status (only for local installs) + let dnsStatus: SystemStatusResponse["data"]["katana"]["dns"] = null; + + if (config.install_type === "local") { + try { + const dnsManager = getDnsManager(); + const managedEntries = await dnsManager.listManaged(); + const state = await stateManager.get(); + + // Build list of expected hostnames (dashboard + installed targets) + const expectedHostnames: string[] = [getDashboardHostname(config)]; + for (const target of state.targets) { + for (const route of target.routes) { + expectedHostnames.push(route.hostname); + } + } + + // Check if all expected hostnames exist in managed entries + // Extra entries are OK (e.g., from `dns sync --all`) + const managedHostnames = new Set(managedEntries.map((e) => e.hostname)); + const allExpectedPresent = expectedHostnames.every((h) => managedHostnames.has(h)); + + dnsStatus = { + inSync: allExpectedPresent, + managedCount: managedEntries.length, + expectedCount: expectedHostnames.length, + }; + } catch { + // DNS check failed + dnsStatus = { + inSync: false, + managedCount: 0, + expectedCount: 0, + }; + } + } + + const response: SystemStatusResponse = { + success: true, + data: { + prerequisites: { + docker: { + installed: dockerVersion !== null, + version: dockerVersion, + daemonRunning, + userCanConnect, + }, + }, + system: systemInfo, + katana: { + certs: { + valid: certsValid, + expiresAt: certsExpiresAt, + daysUntilExpiration, + }, + proxy: { + running: true, // If this endpoint responds, proxy is running + routeCount: routes.size, + bindAddress, + }, + dns: dnsStatus, + }, + }, + }; + + return Response.json(response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const response: ErrorResponse = { success: false, error: message }; + return Response.json(response, { status: 500 }); + } +} + +/** + * POST /api/system/lock + * Lock the system to prevent install/remove operations + */ +export async function handleLock(_req: Request): Promise { + try { + const stateManager = getStateManager(); + await stateManager.update((state) => ({ + ...state, + locked: true, + last_updated: new Date().toISOString(), + })); + + return Response.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const response: ErrorResponse = { success: false, error: message }; + return Response.json(response, { status: 500 }); + } +} + +/** + * POST /api/system/unlock + * Unlock the system to allow install/remove operations + */ +export async function handleUnlock(_req: Request): Promise { + try { + const stateManager = getStateManager(); + await stateManager.update((state) => ({ + ...state, + locked: false, + last_updated: new Date().toISOString(), + })); + + return Response.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const response: ErrorResponse = { success: false, error: message }; + return Response.json(response, { status: 500 }); + } +} diff --git a/src/server/sse.ts b/src/server/sse.ts new file mode 100644 index 0000000..e71a00e --- /dev/null +++ b/src/server/sse.ts @@ -0,0 +1,168 @@ +/** + * Server-Sent Events (SSE) helpers for streaming operation progress + */ + +import { z } from "zod"; + +// ============================================================================= +// SSE Event Types +// ============================================================================= + +/** + * Progress event - indicates operation progress + */ +export const ProgressEventSchema = z.object({ + type: z.literal("progress"), + percent: z.number().min(0).max(100), + message: z.string(), +}); + +export type ProgressEvent = z.infer; + +/** + * Task event - task status update + */ +export const TaskEventSchema = z.object({ + type: z.literal("task"), + name: z.string(), + status: z.enum(["pending", "running", "completed", "failed"]), +}); + +export type TaskEvent = z.infer; + +/** + * Log event - log line from operation + */ +export const LogEventSchema = z.object({ + type: z.literal("log"), + line: z.string(), + level: z.enum(["info", "error"]), +}); + +export type LogEvent = z.infer; + +/** + * Complete event - operation finished + */ +export const CompleteEventSchema = z.object({ + type: z.literal("complete"), + success: z.boolean(), + error: z.string().optional(), + duration: z.number().nonnegative(), // milliseconds +}); + +export type CompleteEvent = z.infer; + +/** + * Union of all SSE event types + */ +export const SSEEventSchema = z.discriminatedUnion("type", [ + ProgressEventSchema, + TaskEventSchema, + LogEventSchema, + CompleteEventSchema, +]); + +export type SSEEvent = z.infer; + +// ============================================================================= +// SSE Formatting +// ============================================================================= + +/** + * Format an event as an SSE message string + */ +export function formatSSEMessage(event: SSEEvent): string { + const data = JSON.stringify(event); + return `event: ${event.type}\ndata: ${data}\n\n`; +} + +/** + * Create a heartbeat message (keeps connection alive) + */ +export function createHeartbeat(): string { + return ": heartbeat\n\n"; +} + +// ============================================================================= +// SSE Stream Creation +// ============================================================================= + +const encoder = new TextEncoder(); + +/** + * Create an SSE ReadableStream with automatic heartbeat + * + * @param onStart - Called when stream starts, receives controller for enqueuing events + * @param heartbeatInterval - Milliseconds between heartbeats (default: 15000) + */ +export function createSSEStream( + onStart: (controller: ReadableStreamDefaultController) => void | Promise, + heartbeatInterval = 15000, +): ReadableStream { + let heartbeatTimer: ReturnType | null = null; + + return new ReadableStream({ + async start(controller) { + // Start heartbeat timer + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(createHeartbeat())); + } catch { + // Controller closed, will be cleaned up in cancel + } + }, heartbeatInterval); + + // Call user's start handler + await onStart(controller); + }, + + cancel() { + // Clean up heartbeat timer + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }, + }); +} + +/** + * Send an SSE event to a controller + */ +export function sendSSEEvent( + controller: ReadableStreamDefaultController, + event: SSEEvent, +): boolean { + try { + const message = formatSSEMessage(event); + controller.enqueue(encoder.encode(message)); + return true; + } catch { + // Controller is closed + return false; + } +} + +/** + * Close an SSE stream gracefully + */ +export function closeSSEStream(controller: ReadableStreamDefaultController): void { + try { + controller.close(); + } catch { + // Already closed + } +} + +/** + * Create SSE Response headers + */ +export function createSSEHeaders(): Headers { + return new Headers({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", // Disable nginx buffering + }); +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..0595c81 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +/** + * Main configuration schema + */ +export const ConfigSchema = z.object({ + /** Installation type: local or remote */ + install_type: z.enum(["local", "remote"]).default("local"), + + /** Base domain for remote installs (e.g., lab01.training.example.com) */ + base_domain: z.string().optional(), + + /** Local domain suffix for local installs */ + local_domain: z.string().default("samurai.wtf"), + + /** Dashboard hostname */ + dashboard_hostname: z.string().default("katana"), + + /** Paths configuration */ + paths: z + .object({ + modules: z.string().default("./modules"), + data: z.string().default("~/.local/share/katana"), + certs: z.string().default("~/.local/share/katana/certs"), + state: z.string().default("~/.local/share/katana/state.yml"), + }) + .default({}), + + /** Proxy configuration */ + proxy: z + .object({ + http_port: z.number().int().min(1).max(65535).default(80), + https_port: z.number().int().min(1).max(65535).default(443), + bind_address: z.string().ip().optional(), + }) + .default({}), + + /** Docker network name */ + docker_network: z.string().default("katana-net"), +}); + +/** + * Inferred Config type from schema + */ +export type Config = z.infer; + +/** + * Default configuration values + */ +export const DEFAULT_CONFIG: Config = { + install_type: "local", + local_domain: "samurai.wtf", + dashboard_hostname: "katana", + paths: { + modules: "./modules", + data: "~/.local/share/katana", + certs: "~/.local/share/katana/certs", + state: "~/.local/share/katana/state.yml", + }, + proxy: { + http_port: 80, + https_port: 443, + bind_address: undefined, + }, + docker_network: "katana-net", +}; + +/** + * Validate and parse config data + */ +export function parseConfig(data: unknown): Config { + return ConfigSchema.parse(data); +} + +/** + * Get the full hostname for a target + */ +export function getTargetHostname(config: Config, targetHostname: string): string { + if (config.install_type === "remote" && config.base_domain) { + return `${targetHostname}.${config.base_domain}`; + } + return `${targetHostname}.${config.local_domain}`; +} + +/** + * Get the dashboard full hostname + */ +export function getDashboardHostname(config: Config): string { + return getTargetHostname(config, config.dashboard_hostname); +} + +/** + * Get the bind address for the proxy server + * Defaults based on install_type: + * - Local installs: 127.0.0.1 (localhost only) + * - Remote installs: 0.0.0.0 (all interfaces) + */ +export function getBindAddress(config: Config): string { + // Explicit config overrides default behavior + if (config.proxy.bind_address) { + return config.proxy.bind_address; + } + + // Smart defaults based on install type + if (config.install_type === "remote") { + return "0.0.0.0"; // All interfaces for remote access + } + + return "127.0.0.1"; // Localhost only for local installs +} diff --git a/src/types/docker.ts b/src/types/docker.ts new file mode 100644 index 0000000..44cf2e5 --- /dev/null +++ b/src/types/docker.ts @@ -0,0 +1,49 @@ +/** + * Docker-related type definitions + */ + +/** + * Container status information + */ +export interface ContainerInfo { + /** Container ID (short form) */ + id: string; + + /** Container name (without leading slash) */ + name: string; + + /** Image name with tag */ + image: string; + + /** Whether container is running */ + running: boolean; + + /** Container state */ + state: "created" | "running" | "paused" | "restarting" | "removing" | "exited" | "dead"; + + /** Uptime in seconds (0 if not running) */ + uptime: number; + + /** Networks container is attached to */ + networks: string[]; + + /** Container labels */ + labels: Record; +} + +/** + * Docker Compose project status + */ +export interface ComposeStatus { + /** Project name (e.g., katana-dvwa) */ + project: string; + + /** All containers in the project */ + containers: ContainerInfo[]; + + /** True if all containers are running */ + all_running: boolean; + + /** True if any container is running */ + any_running: boolean; +} diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..c0f0ede --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,228 @@ +/** + * Base error for all Katana errors + */ +export class KatanaError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = "KatanaError"; + } + + /** + * Suggested fix for the error (optional) + */ + help?(): string; +} + +/** + * Configuration errors + */ +export class ConfigError extends KatanaError { + constructor(message: string) { + super(message, "CONFIG_ERROR"); + this.name = "ConfigError"; + } +} + +/** + * State file errors + */ +export class StateError extends KatanaError { + constructor(message: string) { + super(message, "STATE_ERROR"); + this.name = "StateError"; + } +} + +/** + * Module validation errors + */ +export class ModuleError extends KatanaError { + constructor( + message: string, + public moduleName?: string, + ) { + super(message, "MODULE_ERROR"); + this.name = "ModuleError"; + } +} + +/** + * Docker operation errors + */ +export class DockerError extends KatanaError { + constructor(message: string) { + super(message, "DOCKER_ERROR"); + this.name = "DockerError"; + } +} + +export class DockerNotRunningError extends DockerError { + constructor() { + super("Docker daemon is not running"); + } + + override help() { + return "Run: sudo systemctl start docker"; + } +} + +export class DockerPermissionError extends DockerError { + constructor() { + super("Permission denied accessing Docker socket"); + } + + override help() { + return "Add user to docker group: sudo usermod -aG docker $USER && newgrp docker"; + } +} + +/** + * Certificate errors + */ +export class CertError extends KatanaError { + constructor(message: string) { + super(message, "CERT_ERROR"); + this.name = "CertError"; + } +} + +/** + * Certificate not initialized error + */ +export class CertNotInitializedError extends CertError { + constructor() { + super("Certificates not initialized"); + } + + override help() { + return "Run: katana cert init"; + } +} + +/** + * Certificate expired error + */ +export class CertExpiredError extends CertError { + constructor(daysAgo: number) { + super(`Server certificate expired ${Math.abs(daysAgo)} days ago`); + } + + override help() { + return "Run: katana cert renew"; + } +} + +/** + * OpenSSL not found error + */ +export class OpenSSLNotFoundError extends CertError { + constructor() { + super("OpenSSL command not found"); + } + + override help() { + return "Install OpenSSL: sudo apt install openssl"; + } +} + +/** + * DNS errors + */ +export class DNSError extends KatanaError { + constructor(message: string) { + super(message, "DNS_ERROR"); + this.name = "DNSError"; + } +} + +export class DNSPermissionError extends DNSError { + constructor() { + super("Permission denied modifying /etc/hosts"); + } + + override help() { + return "Run: sudo katana dns sync"; + } +} + +/** + * Proxy errors + */ +export class ProxyError extends KatanaError { + constructor(message: string) { + super(message, "PROXY_ERROR"); + this.name = "ProxyError"; + } +} + +export class PortBindError extends ProxyError { + constructor( + public port: number, + reason?: string, + ) { + super(`Cannot bind to port ${port}${reason ? `: ${reason}` : ""}`); + } + + override help() { + if (this.port < 1024) { + return "Run: sudo katana setup-proxy"; + } + return `Check if another process is using port ${this.port}`; + } +} + +export class ContainerNotReachableError extends ProxyError { + constructor( + public containerName: string, + reason?: string, + ) { + super(`Cannot reach container ${containerName}${reason ? `: ${reason}` : ""}`); + } + + override help() { + return "Verify the target is running with: katana status"; + } +} + +export class RouteNotFoundError extends ProxyError { + constructor(public hostname: string) { + super(`No route found for hostname: ${hostname}`); + } +} + +/** + * Lock errors + */ +export class SystemLockedError extends KatanaError { + constructor() { + super("System is locked - cannot modify targets", "SYSTEM_LOCKED"); + this.name = "SystemLockedError"; + } + + override help() { + return "Run: katana unlock"; + } +} + +/** + * Not found errors + */ +export class NotFoundError extends KatanaError { + constructor(type: string, name: string) { + super(`${type} not found: ${name}`, "NOT_FOUND"); + this.name = "NotFoundError"; + } +} + +/** + * Already exists errors + */ +export class AlreadyExistsError extends KatanaError { + constructor(type: string, name: string) { + super(`${type} already exists: ${name}`, "ALREADY_EXISTS"); + this.name = "AlreadyExistsError"; + } +} diff --git a/src/types/module.ts b/src/types/module.ts new file mode 100644 index 0000000..7e65f8f --- /dev/null +++ b/src/types/module.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +/** + * Proxy configuration for routing requests to a target service + */ +export interface ProxyConfig { + /** Hostname/subdomain (e.g., 'dvwa' - full domain computed at runtime) */ + hostname: string; + + /** Optional subdomain override for remote installs */ + hostname_remote?: string; + + /** Docker Compose service name */ + service: string; + + /** Container port to proxy to */ + port: number; +} + +/** + * Base module structure shared by all module types + */ +export interface BaseModule { + /** Unique module name (lowercase, alphanumeric with hyphens) */ + name: string; + + /** Module category */ + category: "targets" | "tools"; + + /** Human-readable description */ + description: string; + + /** Module directory path (set by loader, not in YAML) */ + path?: string; +} + +/** + * Target module (Docker Compose based) + */ +export interface TargetModule extends BaseModule { + category: "targets"; + + /** Path to compose file (relative to module dir) */ + compose: string; + + /** Proxy routing configuration */ + proxy: ProxyConfig[]; + + /** Optional environment variables for compose templating */ + env?: Record; +} + +/** + * Tool module (script based) + */ +export interface ToolModule extends BaseModule { + category: "tools"; + + /** Path to install script (relative to module dir) */ + install: string; + + /** Path to remove script (relative to module dir) */ + remove: string; + + /** Path to start script (optional) */ + start?: string; + + /** Path to stop script (optional) */ + stop?: string; + + /** Whether install requires root privileges */ + install_requires_root: boolean; +} + +export type Module = TargetModule | ToolModule; + +// ============================================================ +// Zod Schemas for runtime validation +// ============================================================ + +export const ProxyConfigSchema = z.object({ + hostname: z.string().min(1, "Hostname is required"), + hostname_remote: z.string().optional(), + service: z.string().min(1, "Service name is required"), + port: z.number().int().min(1).max(65535), +}); + +export const BaseModuleSchema = z.object({ + name: z + .string() + .regex(/^[a-z0-9-]+$/, "Module name must be lowercase alphanumeric with hyphens only"), + category: z.enum(["targets", "tools"]), + description: z.string().min(1, "Description is required"), +}); + +export const TargetModuleSchema = BaseModuleSchema.extend({ + category: z.literal("targets"), + compose: z.string().min(1, "Compose file path is required"), + proxy: z.array(ProxyConfigSchema).min(1, "At least one proxy configuration is required"), + env: z.record(z.string()).optional(), +}); + +export const ToolModuleSchema = BaseModuleSchema.extend({ + category: z.literal("tools"), + install: z.string().min(1, "Install script path is required"), + remove: z.string().min(1, "Remove script path is required"), + start: z.string().optional(), + stop: z.string().optional(), + install_requires_root: z.boolean().default(false), +}); + +export const ModuleSchema = z.discriminatedUnion("category", [ + TargetModuleSchema, + ToolModuleSchema, +]); + +/** + * Parse and validate module data + * @throws ZodError if validation fails + */ +export function parseModule(data: unknown): Module { + return ModuleSchema.parse(data) as Module; +} + +/** + * Type guard to check if a module is a TargetModule + */ +export function isTargetModule(module: Module): module is TargetModule { + return module.category === "targets"; +} + +/** + * Type guard to check if a module is a ToolModule + */ +export function isToolModule(module: Module): module is ToolModule { + return module.category === "tools"; +} diff --git a/src/types/state.ts b/src/types/state.ts new file mode 100644 index 0000000..cfe43d6 --- /dev/null +++ b/src/types/state.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** + * Proxy route schema + */ +export const ProxyRouteSchema = z.object({ + /** Hostname (e.g., dvwa.test) */ + hostname: z.string(), + + /** Docker service name */ + service: z.string(), + + /** Container port */ + port: z.number().int().min(1).max(65535), +}); + +export type ProxyRoute = z.infer; + +/** + * Target state schema + */ +export const TargetStateSchema = z.object({ + /** Module name */ + name: z.string(), + + /** Installation timestamp (ISO 8601) */ + installed_at: z.string().datetime(), + + /** Docker Compose project name */ + compose_project: z.string(), + + /** Registered proxy routes */ + routes: z.array(ProxyRouteSchema), +}); + +export type TargetState = z.infer; + +/** + * Tool state schema + */ +export const ToolStateSchema = z.object({ + /** Module name */ + name: z.string(), + + /** Installation timestamp (ISO 8601) */ + installed_at: z.string().datetime(), + + /** Tool version (if available) */ + version: z.string().optional(), +}); + +export type ToolState = z.infer; + +/** + * Main state schema + */ +export const StateSchema = z.object({ + /** Lock status - prevents install/remove when true */ + locked: z.boolean().default(false), + + /** Last state update timestamp (ISO 8601) */ + last_updated: z.string().datetime(), + + /** Installed targets */ + targets: z.array(TargetStateSchema).default([]), + + /** Installed tools */ + tools: z.array(ToolStateSchema).default([]), +}); + +export type State = z.infer; + +/** + * Create an empty state object + */ +export function createEmptyState(): State { + return { + locked: false, + last_updated: new Date().toISOString(), + targets: [], + tools: [], + }; +} + +/** + * Validate and parse state data + */ +export function parseState(data: unknown): State { + return StateSchema.parse(data); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 0000000..81d396f --- /dev/null +++ b/src/ui/App.tsx @@ -0,0 +1,245 @@ +/** + * Main Katana Dashboard App + */ + +import { type ActiveOperation, ModuleCard } from "@/ui/components/ModuleCard"; +import { OperationSheet } from "@/ui/components/OperationSheet"; +import { SystemPanel } from "@/ui/components/SystemPanel"; +import { SystemIcon, TargetsIcon, ToolsIcon } from "@/ui/components/icons/TabIcons"; +import { Header } from "@/ui/components/layout/Header"; +import { Skeleton } from "@/ui/components/ui/skeleton"; +import { Toaster } from "@/ui/components/ui/sonner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/components/ui/tabs"; +import { useModules } from "@/ui/hooks/useModules"; +import { useSSE } from "@/ui/hooks/useSSE"; +import { useSystemStatus } from "@/ui/hooks/useSystemStatus"; +import { startOperation } from "@/ui/lib/api"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +function ModuleGrid() { + const { modules, locked, isLoading, error, refetch } = useModules("targets"); + const [operationOpen, setOperationOpen] = useState(false); + const [currentModule, setCurrentModule] = useState(""); + const [currentOperation, setCurrentOperation] = useState< + "install" | "remove" | "start" | "stop" | "" + >(""); + const [completionPulse, setCompletionPulse] = useState<"success" | "error" | null>(null); + + const sse = useSSE({ + onComplete: (success) => { + // NO TOAST - inline card feedback instead + // Trigger pulse animation on the card + setCompletionPulse(success ? "success" : "error"); + setTimeout(() => setCompletionPulse(null), 600); + // Refresh modules list + refetch(); + }, + }); + + const handleOperation = useCallback( + async (name: string, operation: "install" | "remove" | "start" | "stop") => { + try { + setCurrentModule(name); + setCurrentOperation(operation); + setCompletionPulse(null); // Reset any existing pulse + // DON'T auto-open sheet - user can click card to see details + + const response = await startOperation(name, operation); + sse.connect(response.data.operationId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // For startup errors, show toast since there's no card feedback yet + toast.error(`Failed to start ${operation}: ${message}`); + } + }, + [sse], + ); + + const handleOpenDetails = useCallback(() => { + setOperationOpen(true); + }, []); + + // Build activeOperation object for cards + const activeOperation: ActiveOperation | null = + currentModule && currentOperation + ? { + moduleName: currentModule, + operation: currentOperation as "install" | "remove" | "start" | "stop", + progress: sse.progress, + completed: sse.completed, + success: sse.success, + error: sse.error, + } + : null; + + if (error) { + return ( +
+

Error: {error}

+ +
+ ); + } + + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ); + } + + return ( + <> +
+ {modules.map((module) => ( + handleOperation(name, "install")} + onRemove={(name) => handleOperation(name, "remove")} + onStart={(name) => handleOperation(name, "start")} + onStop={(name) => handleOperation(name, "stop")} + onOpenDetails={handleOpenDetails} + /> + ))} +
+ + {modules.length === 0 && ( +
No targets available
+ )} + + + + ); +} + +function ToolsList() { + const { modules, isLoading, error } = useModules("tools"); + + if (error) { + return ( +
+

Error: {error}

+
+ ); + } + + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+ +
+ ))} +
+ ); + } + + const installedTools = modules.filter((m) => m.status !== "not_installed"); + + if (installedTools.length === 0) { + return ( +
+

No tools installed.

+

Tools can be installed via the CLI:

+ katana install zap +
+ ); + } + + return ( +
+ {installedTools.map((tool) => ( +
+
+

{tool.name}

+

{tool.description}

+
+ + Installed + +
+ ))} +
+ ); +} + +function SystemTab() { + const { data, isLoading, error, refetch } = useSystemStatus(); + + return ; +} + +export function App() { + const { locked } = useModules("targets"); + + return ( +
+
+ +
+ + + + + Targets + + + + Tools + + + + System + + + + + + + + + + + + + + + +
+ + +
+ ); +} diff --git a/src/ui/build.ts b/src/ui/build.ts new file mode 100644 index 0000000..dcd5d72 --- /dev/null +++ b/src/ui/build.ts @@ -0,0 +1,98 @@ +/** + * UI Build Script + * Bundles the React app and CSS for production + */ + +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +const srcDir = import.meta.dir; +const distDir = join(srcDir, "dist"); + +async function build() { + console.log("Building UI..."); + + // Ensure dist directory exists + await mkdir(distDir, { recursive: true }); + + // Build CSS with Tailwind CLI v4 + console.log(" Building CSS..."); + const cssResult = Bun.spawnSync([ + "bunx", + "@tailwindcss/cli", + "-i", + join(srcDir, "globals.css"), + "-o", + join(distDir, "styles.css"), + "--minify", + ]); + + if (cssResult.exitCode !== 0) { + console.error("CSS build failed:"); + console.error(cssResult.stderr.toString()); + process.exit(1); + } + + // Bundle the React app + console.log(" Building JS..."); + const result = await Bun.build({ + entrypoints: [join(srcDir, "main.tsx")], + outdir: distDir, + naming: "app.js", + minify: true, + sourcemap: "external", + target: "browser", + define: { + "process.env.NODE_ENV": '"production"', + }, + }); + + if (!result.success) { + console.error("JS build failed:"); + for (const log of result.logs) { + console.error(log); + } + process.exit(1); + } + + // Generate index.html + const html = ` + + + + + Katana Dashboard + + + +
+ + +`; + + await Bun.write(join(distDir, "index.html"), html); + + // Read the built files + const cssContent = await Bun.file(join(distDir, "styles.css")).text(); + const jsContent = await Bun.file(join(distDir, "app.js")).text(); + + // Generate embedded assets module for compile-time inclusion + const embeddedModule = `// Auto-generated by build.ts - DO NOT EDIT +// This file embeds UI assets for the compiled binary + +export const embeddedAssets = { + "index.html": ${JSON.stringify(html)}, + "styles.css": ${JSON.stringify(cssContent)}, + "app.js": ${JSON.stringify(jsContent)}, +} as const; + +export type AssetName = keyof typeof embeddedAssets; +`; + + await Bun.write(join(srcDir, "embedded-assets.ts"), embeddedModule); + + console.log("Build complete! Output in src/ui/dist/"); + console.log(" Generated embedded-assets.ts for compiled binary"); +} + +build(); diff --git a/src/ui/components/ModuleCard.tsx b/src/ui/components/ModuleCard.tsx new file mode 100644 index 0000000..a86ce66 --- /dev/null +++ b/src/ui/components/ModuleCard.tsx @@ -0,0 +1,276 @@ +import { NinjaStarSpinner } from "@/ui/components/icons/NinjaStarSpinner"; +import { Badge } from "@/ui/components/ui/badge"; +import { Button } from "@/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/ui/components/ui/card"; +import type { ModuleInfo, ModuleStatus } from "@/ui/lib/api"; +import { Download, ExternalLink, Info, Play, Square, Trash2, XCircle } from "lucide-react"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface ActiveOperation { + moduleName: string; + operation: "install" | "remove" | "start" | "stop"; + progress: number; + completed: boolean; + success: boolean; + error: string | null; +} + +interface ModuleCardProps { + module: ModuleInfo; + locked: boolean; + activeOperation?: ActiveOperation | null; + completionPulse?: "success" | "error" | null; + onInstall: (name: string) => void; + onRemove: (name: string) => void; + onStart: (name: string) => void; + onStop: (name: string) => void; + onOpenDetails?: () => void; +} + +// ============================================================================= +// Helper Components +// ============================================================================= + +/** Badge showing spinning ninja star + operation verb */ +function OperationBadge({ operation }: { operation: string }) { + const verb: Record = { + install: "Installing", + remove: "Removing", + start: "Starting", + stop: "Stopping", + }; + + return ( + + + {verb[operation] || operation}... + + ); +} + +/** Thin progress bar at bottom of card */ +function CardProgressBar({ progress }: { progress: number }) { + return ( +
+
+
+ ); +} + +// ============================================================================= +// Status Helpers +// ============================================================================= + +function getStatusBadgeVariant( + status: ModuleStatus, +): "default" | "secondary" | "destructive" | "outline" { + switch (status) { + case "running": + return "default"; + case "stopped": + return "secondary"; + case "installed": + return "secondary"; + case "not_installed": + return "outline"; + default: + return "outline"; + } +} + +function getStatusLabel(status: ModuleStatus): string { + switch (status) { + case "running": + return "Running"; + case "stopped": + return "Stopped"; + case "installed": + return "Installed"; + case "not_installed": + return "Not Installed"; + default: + return "Unknown"; + } +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function ModuleCard({ + module, + locked, + activeOperation, + completionPulse, + onInstall, + onRemove, + onStart, + onStop, + onOpenDetails, +}: ModuleCardProps) { + const { name, description, status, hrefs } = module; + + // Check if this card has the active operation + const isActive = activeOperation?.moduleName === name; + const isOperating = isActive && !activeOperation?.completed; + const hasError = isActive && activeOperation?.completed && !activeOperation?.success; + + // Derived state + const isInstalled = status !== "not_installed"; + const isRunning = status === "running"; + const buttonsDisabled = locked || isOperating; + + // Determine which badge to show + const renderBadge = () => { + if (isOperating && activeOperation) { + return ; + } + if (hasError) { + return ( + + + Error + + ); + } + return {getStatusLabel(status)}; + }; + + // Build card className with animation + const cardClasses = [ + "flex flex-col relative", + completionPulse === "success" && "animate-pulse-success", + completionPulse === "error" && "animate-pulse-error", + isOperating && "cursor-pointer ring-1 ring-primary/20", + ] + .filter(Boolean) + .join(" "); + + const handleCardClick = () => { + if (isOperating && onOpenDetails) { + onOpenDetails(); + } + }; + + return ( + + +
+ {name} + {renderBadge()} +
+ {description} +
+ + + {/* Show links when running */} + {isRunning && hrefs.length > 0 && ( + + )} + + {/* Show "click for details" hint when operating */} + {isOperating && ( +
+ + Click for details +
+ )} + + {/* Show error hint when failed */} + {hasError && ( + + )} +
+ + + {!isInstalled && ( + + )} + + {isInstalled && !isRunning && ( + <> + + + + )} + + {isRunning && ( + <> + + + + )} + + + {/* Progress bar at bottom when operating */} + {isOperating && activeOperation && } +
+ ); +} diff --git a/src/ui/components/OperationSheet.tsx b/src/ui/components/OperationSheet.tsx new file mode 100644 index 0000000..6f27d72 --- /dev/null +++ b/src/ui/components/OperationSheet.tsx @@ -0,0 +1,181 @@ +import { Badge } from "@/ui/components/ui/badge"; +import { Button } from "@/ui/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/ui/components/ui/collapsible"; +import { Progress } from "@/ui/components/ui/progress"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/ui/components/ui/sheet"; +import type { SSELog, SSETask } from "@/ui/hooks/useSSE"; +import { CheckCircle2, ChevronDown, Circle, Loader2, XCircle } from "lucide-react"; +import { useEffect, useRef } from "react"; + +interface OperationSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + moduleName: string; + operation: string; + progress: number; + progressMessage: string; + tasks: SSETask[]; + logs: SSELog[]; + completed: boolean; + success: boolean; + error: string | null; + duration: number | null; +} + +function TaskIcon({ status }: { status: SSETask["status"] }) { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "running": + return ; + default: + return ; + } +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +export function OperationSheet({ + open, + onOpenChange, + moduleName, + operation, + progress, + progressMessage, + tasks, + logs, + completed, + success, + error, + duration, +}: OperationSheetProps) { + const logsEndRef = useRef(null); + + // Auto-scroll logs when new entries are added + const logsLength = logs.length; + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally triggering on logs.length + useEffect(() => { + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logsLength]); + + const operationLabel = operation.charAt(0).toUpperCase() + operation.slice(1); + + return ( + + + + + {completed ? ( + success ? ( + + ) : ( + + ) + ) : ( + + )} + {operationLabel} {moduleName} + + + {completed + ? success + ? `Successfully ${operation}ed ${moduleName}` + : `Failed to ${operation} ${moduleName}` + : `${operationLabel}ing ${moduleName}...`} + + + +
+ {/* Progress */} +
+
+ {progressMessage} + {Math.round(progress)}% +
+ +
+ + {/* Status badges */} +
+ {completed && ( + + {success ? "Completed" : "Failed"} + + )} + {duration !== null && ( + Duration: {formatDuration(duration)} + )} +
+ + {/* Error message */} + {error && ( +
{error}
+ )} + + {/* Tasks */} + {tasks.length > 0 && ( +
+

Tasks

+
+ {tasks.map((task) => ( +
+ + + {task.name} + +
+ ))} +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( + + + + + +
+ {logs.map((log, index) => ( +
+ {log.line} +
+ ))} +
+
+ + + )} +
+ + + ); +} diff --git a/src/ui/components/SystemPanel.tsx b/src/ui/components/SystemPanel.tsx new file mode 100644 index 0000000..2dfb8d0 --- /dev/null +++ b/src/ui/components/SystemPanel.tsx @@ -0,0 +1,218 @@ +import { Badge } from "@/ui/components/ui/badge"; +import { Button } from "@/ui/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/ui/components/ui/card"; +import { Progress } from "@/ui/components/ui/progress"; +import { Skeleton } from "@/ui/components/ui/skeleton"; +import { type SystemStatus, getCACertUrl } from "@/ui/lib/api"; +import { AlertCircle, CheckCircle2, Download, RefreshCw, XCircle } from "lucide-react"; + +interface SystemPanelProps { + data: SystemStatus | null; + isLoading: boolean; + error: string | null; + onRefresh: () => void; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; +} + +function StatusIcon({ ok }: { ok: boolean }) { + return ok ? ( + + ) : ( + + ); +} + +function StatusItem({ + label, + value, + ok, +}: { + label: string; + value: string; + ok?: boolean; +}) { + return ( +
+ {label} +
+ {ok !== undefined && } + {value} +
+
+ ); +} + +export function SystemPanel({ data, isLoading, error, onRefresh }: SystemPanelProps) { + if (error) { + return ( + + +
+ + + Error Loading Status + + +
+ {error} +
+
+ ); + } + + if (isLoading || !data) { + return ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + + + + + ))} +
+ ); + } + + return ( +
+ {/* Refresh button */} +
+ +
+ +
+ {/* Prerequisites Card */} + + + Prerequisites + Required system components + + + + + + + + + {/* System Resources Card */} + + + System Resources + + {data.system.os} {data.system.kernel} + + + +
+
+ Memory + + {formatBytes(data.system.memory.used)} / {formatBytes(data.system.memory.total)} + +
+ +
+
+
+ Disk ({data.system.disk.path}) + + {formatBytes(data.system.disk.used)} / {formatBytes(data.system.disk.total)} + +
+ +
+ +
+
+ + {/* Katana Status Card */} + + + Katana Status + Lab environment status + + + + + {data.katana.dns !== null && ( + + )} + + {data.katana.certs.valid && ( + + )} + + +
+
+ ); +} diff --git a/src/ui/components/ThemeToggle.tsx b/src/ui/components/ThemeToggle.tsx new file mode 100644 index 0000000..6325581 --- /dev/null +++ b/src/ui/components/ThemeToggle.tsx @@ -0,0 +1,19 @@ +import { Button } from "@/ui/components/ui/button"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/src/ui/components/icons/NinjaStarSpinner.tsx b/src/ui/components/icons/NinjaStarSpinner.tsx new file mode 100644 index 0000000..fb956cb --- /dev/null +++ b/src/ui/components/icons/NinjaStarSpinner.tsx @@ -0,0 +1,22 @@ +/** + * Spinning Ninja Star (Shuriken) indicator for operation progress + */ + +interface NinjaStarSpinnerProps { + className?: string; + spinning?: boolean; +} + +export function NinjaStarSpinner({ className = "", spinning = true }: NinjaStarSpinnerProps) { + return ( + + ); +} diff --git a/src/ui/components/icons/TabIcons.tsx b/src/ui/components/icons/TabIcons.tsx new file mode 100644 index 0000000..178a869 --- /dev/null +++ b/src/ui/components/icons/TabIcons.tsx @@ -0,0 +1,74 @@ +interface IconProps { + className?: string; +} + +export function TargetsIcon({ className = "h-4 w-4" }: IconProps) { + return ( + + + + + + + ); +} + +export function ToolsIcon({ className = "h-4 w-4" }: IconProps) { + return ( + + + + + ); +} + +export function SystemIcon({ className = "h-4 w-4" }: IconProps) { + return ( + + + + + ); +} diff --git a/src/ui/components/layout/Header.tsx b/src/ui/components/layout/Header.tsx new file mode 100644 index 0000000..928c1f6 --- /dev/null +++ b/src/ui/components/layout/Header.tsx @@ -0,0 +1,37 @@ +import { Button } from "@/ui/components/ui/button"; +import { useTheme } from "@/ui/hooks/useTheme"; +import { Lock, Moon, Sun } from "lucide-react"; +import { Logo } from "./Logo"; + +interface HeaderProps { + locked?: boolean; + lockMessage?: string; +} + +export function Header({ locked, lockMessage }: HeaderProps) { + const { isDark, toggleTheme } = useTheme(); + + return ( +
+
+ {/* Centered logo */} + + + {/* Right side controls */} +
+ {locked && ( +
+ +
+ )} + +
+
+
+ ); +} diff --git a/src/ui/components/layout/Logo.tsx b/src/ui/components/layout/Logo.tsx new file mode 100644 index 0000000..26e93b8 --- /dev/null +++ b/src/ui/components/layout/Logo.tsx @@ -0,0 +1,69 @@ +/** + * Katana Logo Component + * + * Inline SVG that uses currentColor to adapt to light/dark themes. + * The logo renders as foreground color (dark in light mode, light in dark mode). + */ + +interface LogoProps { + className?: string; + size?: "sm" | "md" | "lg" | "xl"; +} + +const sizeClasses = { + sm: "h-8", + md: "h-12", + lg: "h-16", + xl: "h-20", +}; + +export function Logo({ className = "", size = "md" }: LogoProps) { + return ( + + {/* Main katana/samurai emblem */} + + + {/* Eye/circular detail - slightly lighter */} + + + + + {/* KATANA text */} + + + + + + + + + + + + + + ); +} diff --git a/src/ui/components/ui/badge.tsx b/src/ui/components/ui/badge.tsx new file mode 100644 index 0000000..e786632 --- /dev/null +++ b/src/ui/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/ui/components/ui/button.tsx b/src/ui/components/ui/button.tsx new file mode 100644 index 0000000..42c7f4d --- /dev/null +++ b/src/ui/components/ui/button.tsx @@ -0,0 +1,60 @@ +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/src/ui/components/ui/card.tsx b/src/ui/components/ui/card.tsx new file mode 100644 index 0000000..0a15a4f --- /dev/null +++ b/src/ui/components/ui/card.tsx @@ -0,0 +1,75 @@ +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/src/ui/components/ui/collapsible.tsx b/src/ui/components/ui/collapsible.tsx new file mode 100644 index 0000000..ecddbb5 --- /dev/null +++ b/src/ui/components/ui/collapsible.tsx @@ -0,0 +1,19 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +function Collapsible({ ...props }: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/ui/components/ui/progress.tsx b/src/ui/components/ui/progress.tsx new file mode 100644 index 0000000..23c4d73 --- /dev/null +++ b/src/ui/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/ui/components/ui/sheet.tsx b/src/ui/components/ui/sheet.tsx new file mode 100644 index 0000000..7b9247e --- /dev/null +++ b/src/ui/components/ui/sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/src/ui/components/ui/skeleton.tsx b/src/ui/components/ui/skeleton.tsx new file mode 100644 index 0000000..f328713 --- /dev/null +++ b/src/ui/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/ui/lib/utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/src/ui/components/ui/sonner.tsx b/src/ui/components/ui/sonner.tsx new file mode 100644 index 0000000..28c5ec3 --- /dev/null +++ b/src/ui/components/ui/sonner.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/ui/components/ui/tabs.tsx b/src/ui/components/ui/tabs.tsx new file mode 100644 index 0000000..1441817 --- /dev/null +++ b/src/ui/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type * as React from "react"; + +import { cn } from "@/ui/lib/utils"; + +function Tabs({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/ui/globals.css b/src/ui/globals.css new file mode 100644 index 0000000..9b075d2 --- /dev/null +++ b/src/ui/globals.css @@ -0,0 +1,133 @@ +@import "tailwindcss"; +@plugin "tailwindcss-animate"; + +@custom-variant dark (&:is(.dark *)); + +/* Light mode colors as CSS custom properties */ +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + + /* Chart colors */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; +} + +/* Dark mode colors */ +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + /* Chart colors dark */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; +} + +/* Theme mapping to Tailwind - uses CSS variables for runtime theming */ +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius: 0.5rem; +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + font-family: system-ui, -apple-system, sans-serif; + } +} + +/* Operation completion pulse animations */ +@keyframes pulse-success { + 0% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); + } +} + +@keyframes pulse-error { + 0% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } +} + +.animate-pulse-success { + animation: pulse-success 0.6s ease-out; +} + +.animate-pulse-error { + animation: pulse-error 0.6s ease-out; +} diff --git a/src/ui/hooks/useModules.ts b/src/ui/hooks/useModules.ts new file mode 100644 index 0000000..0565d8d --- /dev/null +++ b/src/ui/hooks/useModules.ts @@ -0,0 +1,54 @@ +import { type ModuleInfo, fetchModules } from "@/ui/lib/api"; +import { useCallback, useEffect, useState } from "react"; + +interface UseModulesResult { + modules: ModuleInfo[]; + locked: boolean; + lockMessage?: string; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +export function useModules(category?: "targets" | "tools"): UseModulesResult { + const [modules, setModules] = useState([]); + const [locked, setLocked] = useState(false); + const [lockMessage, setLockMessage] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchModules(category); + + // When locked, only show installed modules + const filteredModules = response.data.locked + ? response.data.modules.filter((m) => m.status !== "not_installed") + : response.data.modules; + + setModules(filteredModules); + setLocked(response.data.locked); + setLockMessage(response.data.lockMessage); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } finally { + setIsLoading(false); + } + }, [category]); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { + modules, + locked, + lockMessage, + isLoading, + error, + refetch, + }; +} diff --git a/src/ui/hooks/useSSE.ts b/src/ui/hooks/useSSE.ts new file mode 100644 index 0000000..2f73d29 --- /dev/null +++ b/src/ui/hooks/useSSE.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SSETask { + name: string; + status: "pending" | "running" | "completed" | "failed"; +} + +export interface SSELog { + line: string; + level: "info" | "error"; + timestamp: Date; +} + +export interface SSEState { + connected: boolean; + progress: number; + progressMessage: string; + tasks: SSETask[]; + logs: SSELog[]; + completed: boolean; + success: boolean; + error: string | null; + duration: number | null; +} + +interface UseSSEOptions { + onComplete?: (success: boolean, error?: string) => void; +} + +interface UseSSEResult extends SSEState { + connect: (operationId: string) => void; + disconnect: () => void; +} + +// ============================================================================= +// Hook +// ============================================================================= + +export function useSSE(options?: UseSSEOptions): UseSSEResult { + const [state, setState] = useState({ + connected: false, + progress: 0, + progressMessage: "", + tasks: [], + logs: [], + completed: false, + success: false, + error: null, + duration: null, + }); + + const eventSourceRef = useRef(null); + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + setState((prev) => ({ ...prev, connected: false })); + } + }, []); + + const connect = useCallback( + (operationId: string) => { + // Close any existing connection + disconnect(); + + // Reset state + setState({ + connected: true, + progress: 0, + progressMessage: "Starting...", + tasks: [], + logs: [], + completed: false, + success: false, + error: null, + duration: null, + }); + + const eventSource = new EventSource(`/api/operations/${operationId}/stream`); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setState((prev) => ({ ...prev, connected: true })); + }; + + eventSource.onerror = () => { + setState((prev) => { + // Don't set error if operation already completed successfully + if (prev.completed) { + return { ...prev, connected: false }; + } + return { + ...prev, + connected: false, + error: prev.error || "Connection lost", + }; + }); + }; + + // Handle progress events + eventSource.addEventListener("progress", (event) => { + const data = JSON.parse(event.data); + setState((prev) => ({ + ...prev, + progress: data.percent ?? prev.progress, + progressMessage: data.message ?? prev.progressMessage, + })); + }); + + // Handle task events + eventSource.addEventListener("task", (event) => { + const data = JSON.parse(event.data); + setState((prev) => { + const existingIndex = prev.tasks.findIndex((t) => t.name === data.name); + const newTasks = [...prev.tasks]; + + if (existingIndex >= 0) { + newTasks[existingIndex] = { name: data.name, status: data.status }; + } else { + newTasks.push({ name: data.name, status: data.status }); + } + + return { ...prev, tasks: newTasks }; + }); + }); + + // Handle log events + eventSource.addEventListener("log", (event) => { + const data = JSON.parse(event.data); + setState((prev) => ({ + ...prev, + logs: [ + ...prev.logs, + { + line: data.line, + level: data.level || "info", + timestamp: new Date(), + }, + ], + })); + }); + + // Handle complete event + eventSource.addEventListener("complete", (event) => { + const data = JSON.parse(event.data); + setState((prev) => ({ + ...prev, + completed: true, + success: data.success, + error: data.error || null, + duration: data.duration || null, + progress: data.success ? 100 : prev.progress, + })); + + // Close connection + eventSource.close(); + eventSourceRef.current = null; + + // Call onComplete callback + if (options?.onComplete) { + options.onComplete(data.success, data.error); + } + }); + }, + [disconnect, options], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + }; + }, []); + + return { + ...state, + connect, + disconnect, + }; +} diff --git a/src/ui/hooks/useSystemStatus.ts b/src/ui/hooks/useSystemStatus.ts new file mode 100644 index 0000000..b069df3 --- /dev/null +++ b/src/ui/hooks/useSystemStatus.ts @@ -0,0 +1,40 @@ +import { type SystemStatus, fetchSystemStatus } from "@/ui/lib/api"; +import { useCallback, useEffect, useState } from "react"; + +interface UseSystemStatusResult { + data: SystemStatus | null; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +export function useSystemStatus(): UseSystemStatusResult { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchSystemStatus(); + setData(response.data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { + data, + isLoading, + error, + refetch, + }; +} diff --git a/src/ui/hooks/useTheme.ts b/src/ui/hooks/useTheme.ts new file mode 100644 index 0000000..3b0ce4f --- /dev/null +++ b/src/ui/hooks/useTheme.ts @@ -0,0 +1,16 @@ +import { useTheme as useNextTheme } from "next-themes"; + +export function useTheme() { + const { setTheme, resolvedTheme } = useNextTheme(); + + const toggleTheme = () => { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + }; + + return { + theme: resolvedTheme ?? "light", + setTheme, + toggleTheme, + isDark: resolvedTheme === "dark", + }; +} diff --git a/src/ui/lib/api.ts b/src/ui/lib/api.ts new file mode 100644 index 0000000..d6d178a --- /dev/null +++ b/src/ui/lib/api.ts @@ -0,0 +1,167 @@ +/** + * API client for the Katana dashboard + */ + +// ============================================================================= +// Types +// ============================================================================= + +export type ModuleStatus = "not_installed" | "installed" | "running" | "stopped" | "unknown"; + +export interface ModuleInfo { + name: string; + category: "targets" | "tools"; + description: string; + status: ModuleStatus; + hrefs: string[]; +} + +export interface ModulesResponse { + success: true; + data: { + modules: ModuleInfo[]; + locked: boolean; + lockMessage?: string; + }; +} + +export interface OperationResponse { + success: true; + data: { + operationId: string; + }; +} + +export interface SystemStatus { + prerequisites: { + docker: { + installed: boolean; + version: string | null; + daemonRunning: boolean; + userCanConnect: boolean; + }; + }; + system: { + os: string; + kernel: string; + uptime: string; + memory: { + total: number; + used: number; + percentUsed: number; + }; + disk: { + path: string; + total: number; + used: number; + percentUsed: number; + }; + }; + katana: { + certs: { + valid: boolean; + expiresAt: string | null; + daysUntilExpiration: number | null; + }; + proxy: { + running: boolean; + routeCount: number; + }; + dns: { + inSync: boolean; + managedCount: number; + expectedCount: number; + } | null; + }; +} + +export interface SystemStatusResponse { + success: true; + data: SystemStatus; +} + +export interface ErrorResponse { + success: false; + error: string; +} + +export type ApiResponse = T | ErrorResponse; + +// ============================================================================= +// API Client +// ============================================================================= + +class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +async function handleResponse(response: Response): Promise { + const data = await response.json(); + + if (!response.ok || data.success === false) { + throw new ApiError(data.error || `HTTP ${response.status}`, response.status); + } + + return data as T; +} + +/** + * Fetch all modules with their status + */ +export async function fetchModules(category?: "targets" | "tools"): Promise { + const url = category ? `/api/modules?category=${category}` : "/api/modules"; + const response = await fetch(url); + return handleResponse(response); +} + +/** + * Start an operation on a module (install, remove, start, stop) + */ +export async function startOperation( + moduleName: string, + operation: "install" | "remove" | "start" | "stop", +): Promise { + const response = await fetch(`/api/modules/${moduleName}/${operation}`, { + method: "POST", + }); + return handleResponse(response); +} + +/** + * Fetch system status + */ +export async function fetchSystemStatus(): Promise { + const response = await fetch("/api/system"); + return handleResponse(response); +} + +/** + * Lock the system to prevent changes + */ +export async function lockSystem(): Promise<{ success: true }> { + const response = await fetch("/api/system/lock", { method: "POST" }); + return handleResponse<{ success: true }>(response); +} + +/** + * Unlock the system to allow changes + */ +export async function unlockSystem(): Promise<{ success: true }> { + const response = await fetch("/api/system/unlock", { method: "POST" }); + return handleResponse<{ success: true }>(response); +} + +/** + * Get the CA certificate download URL + */ +export function getCACertUrl(): string { + return "/api/certs/ca"; +} + +export { ApiError }; diff --git a/src/ui/lib/utils.ts b/src/ui/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/src/ui/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/ui/main.tsx b/src/ui/main.tsx new file mode 100644 index 0000000..167224c --- /dev/null +++ b/src/ui/main.tsx @@ -0,0 +1,12 @@ +import { ThemeProvider } from "next-themes"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (root) { + createRoot(root).render( + + + , + ); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..6c86af5 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,54 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +export class Logger { + constructor(private level: LogLevel = LogLevel.INFO) {} + + setLevel(level: LogLevel): void { + this.level = level; + } + + debug(message: string, ...args: unknown[]): void { + if (this.level <= LogLevel.DEBUG) { + console.debug(`[DEBUG] ${message}`, ...args); + } + } + + info(message: string, ...args: unknown[]): void { + if (this.level <= LogLevel.INFO) { + console.log(message, ...args); + } + } + + warn(message: string, ...args: unknown[]): void { + if (this.level <= LogLevel.WARN) { + console.warn(`[WARN] ${message}`, ...args); + } + } + + error(message: string, ...args: unknown[]): void { + if (this.level <= LogLevel.ERROR) { + console.error(`[ERROR] ${message}`, ...args); + } + } + + /** + * Print a success message (always shown) + */ + success(message: string, ...args: unknown[]): void { + console.log(`✓ ${message}`, ...args); + } + + /** + * Print a failure message (always shown) + */ + fail(message: string, ...args: unknown[]): void { + console.log(`✗ ${message}`, ...args); + } +} + +export const logger = new Logger(); diff --git a/src/utils/paths.ts b/src/utils/paths.ts new file mode 100644 index 0000000..cf10e08 --- /dev/null +++ b/src/utils/paths.ts @@ -0,0 +1,94 @@ +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +/** + * Resolve path with tilde expansion + */ +export function resolvePath(path: string): string { + if (path.startsWith("~/")) { + return join(homedir(), path.slice(2)); + } + if (path === "~") { + return homedir(); + } + return resolve(path); +} + +/** + * Get the config directory path + * When running with sudo, automatically uses the original user's config directory + */ +export function getConfigDir(): string { + // Check if running under sudo + const sudoUser = process.env.SUDO_USER; + + if (sudoUser) { + // Use the original user's config directory + return `/home/${sudoUser}/.config/katana`; + } + + // Normal case - use current user's config + return resolvePath("~/.config/katana"); +} + +/** + * Get the config file path + * When running with sudo, automatically uses the original user's config + */ +export function getConfigPath(): string { + return join(getConfigDir(), "config.yml"); +} + +/** + * Get the data directory path + * When running with sudo, automatically uses the original user's data directory + */ +export function getDataPath(): string { + // Check if running under sudo + const sudoUser = process.env.SUDO_USER; + + if (sudoUser) { + // Use the original user's data directory + return `/home/${sudoUser}/.local/share/katana`; + } + + // Normal case - use current user's data + return resolvePath("~/.local/share/katana"); +} + +/** + * Get the state file path + */ +export function getStatePath(): string { + return join(getDataPath(), "state.yml"); +} + +/** + * Get the certs directory path + */ +export function getCertsPath(): string { + return join(getDataPath(), "certs"); +} + +/** + * Ensure a directory exists, creating it if necessary + */ +export async function ensureDir(path: string): Promise { + const resolvedPath = resolvePath(path); + const file = Bun.file(resolvedPath); + + // Check if path exists + const exists = await file.exists(); + if (!exists) { + // Create directory recursively + await Bun.spawn(["mkdir", "-p", resolvedPath]).exited; + } +} + +/** + * Ensure parent directory exists for a file path + */ +export async function ensureParentDir(filePath: string): Promise { + const dir = dirname(resolvePath(filePath)); + await ensureDir(dir); +} diff --git a/test/_test-arrrspace.sh.disabled b/test/_test-arrrspace.sh.disabled deleted file mode 100644 index 6b743ca..0000000 --- a/test/_test-arrrspace.sh.disabled +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install arrrspace - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://arrrspace.test:80/ -curl --fail -o /dev/null --retry 5 --retry-all-errors http://arrrspace.wtf:80/ -curl --fail -o /dev/null --retry 5 --retry-all-errors http://api.arrrspace.test:80/ -curl --fail -o /dev/null --retry 5 --retry-all-errors http://api.arrrspace.wtf:80/ - -katana remove arrrspace - -echo -e "\nPASSED\n" diff --git a/test/_test-katana.sh.disabled b/test/_test-katana.sh.disabled deleted file mode 100755 index b983c20..0000000 --- a/test/_test-katana.sh.disabled +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Source common test utilities -source "$(dirname "$0")/lib.sh" - -# Install and start the service -install_package katana -echo "Waiting for installation to complete..." -sleep 5 - -start_package katana -echo "Waiting for service to initialize..." -sleep 10 - -# Function to test endpoint with exponential backoff -test_endpoint() { - local url=$1 - local max_attempts=5 - local attempt=1 - local timeout=10 - - while [ $attempt -le $max_attempts ]; do - echo "Testing $url (attempt $attempt/$max_attempts)" - if curl -s -o /dev/null -w "%{http_code}" $url | grep -q "^[23]"; then - echo "Success: $url is responding with 2xx/3xx" - return 0 - fi - sleep $((2 ** (attempt - 1))) # Exponential backoff: 1, 2, 4, 8, 16 seconds - attempt=$((attempt + 1)) - done - echo "Failed to connect to $url after $max_attempts attempts" - return 1 -} - -# Test each endpoint -echo "Testing HTTP endpoint..." -test_endpoint "http://localhost:8087/" || exit 1 - -echo "Testing HTTPS endpoints..." -test_endpoint "https://katana.test:8443/" -- -k || exit 1 -test_endpoint "https://katana.wtf:8443/" -- -k || exit 1 - -# Cleanup -stop_package katana -echo "Waiting for service to stop..." -sleep 5 - -remove_package katana - -echo -e "\nPASSED\n" diff --git a/test/_test-mutillidae.sh.disabled b/test/_test-mutillidae.sh.disabled deleted file mode 100755 index cad5267..0000000 --- a/test/_test-mutillidae.sh.disabled +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install mutillidae -sleep 2 -katana start mutillidae - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:33081/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://mutillidae.test:8443/ - -katana stop mutillidae -sleep 2 -katana remove mutillidae - -echo -e "\nPASSED\n" diff --git a/test/_test-samurai-dojo.sh.disabled b/test/_test-samurai-dojo.sh.disabled deleted file mode 100644 index 48aa457..0000000 --- a/test/_test-samurai-dojo.sh.disabled +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install samurai-dojo -sleep 2 -katana start samurai-dojo - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:30080/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://dojo-basic.test:8443/ - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:31080/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://dojo-scavenger.test:8443/ - -katana stop samurai-dojo -sleep 2 -katana remove samurai-dojo - -echo -e "\nPASSED\n" diff --git a/test/lib.sh b/test/lib.sh deleted file mode 100644 index 6eebdd1..0000000 --- a/test/lib.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash - -# Common test utilities for Katana test scripts - -# Test an HTTP endpoint with exponential backoff -# Arguments: -# $1: URL to test -# $2: Expected status code (optional, accepts 2xx/3xx if not specified) -# Additional flags can be passed after -- e.g., test_endpoint "https://example.com" 200 -- -H "Custom: Header" -test_endpoint() { - local url=$1 - local expected_code=$2 - local max_attempts=5 - local attempt=1 - local timeout=10 - local curl_flags="--fail --max-time $timeout" # Always use --fail and timeout - - # If URL starts with https://, automatically add -k - if [[ "$url" == https://* ]]; then - curl_flags="$curl_flags -k" - fi - - # If we find -- in the arguments, everything after it becomes additional curl flags - local found_separator=false - for arg in "${@:3}"; do - if [ "$arg" = "--" ]; then - found_separator=true - continue - fi - if [ "$found_separator" = true ]; then - curl_flags="$curl_flags $arg" - fi - done - - while [ $attempt -le $max_attempts ]; do - echo "Testing $url (attempt $attempt/$max_attempts)" - echo "Running: curl -s -w \"\n%{http_code}\" $curl_flags \"$url\"" - - # Use curl in a way that captures both status code and connection errors - local output - local status - output=$(curl -s -w "\n%{http_code}" $curl_flags "$url" 2>&1) - status=$? - - # Extract the status code from the last line - local response=$(echo "$output" | tail -n1) - local content=$(echo "$output" | sed '$d') - - # Check for curl errors (connection refused, timeout, etc.) - if [ $status -ne 0 ]; then - echo "Connection failed: $content" - if [ $attempt -eq $max_attempts ]; then - return 1 - fi - sleep $((2 ** (attempt - 1))) # Exponential backoff: 1, 2, 4, 8, 16 seconds - attempt=$((attempt + 1)) - continue - fi - - if [ -n "$expected_code" ]; then - # Check for exact status code match - if [ "$response" = "$expected_code" ]; then - echo "Success: $url responded with expected status $expected_code" - return 0 - fi - else - # Accept any 2xx or 3xx response - if echo "$response" | grep -q "^[23]"; then - echo "Success: $url responded with status $response" - return 0 - fi - fi - - echo "Got unexpected status code: $response" - sleep $((2 ** (attempt - 1))) # Exponential backoff: 1, 2, 4, 8, 16 seconds - attempt=$((attempt + 1)) - done - - echo "Failed to get expected response from $url after $max_attempts attempts" - return 1 -} - -# Wait for service to be ready -# Arguments: -# $1: Service name (for logging) -# $2: Number of seconds to wait -wait_for_service() { - local service_name=$1 - local wait_time=${2:-10} # Default to 10 seconds if not specified - - echo "Waiting $wait_time seconds for $service_name to initialize..." - sleep "$wait_time" -} - -# Standard installation wrapper -# Arguments: -# $1: Package name -# $2: Initial wait time (optional, defaults to 5) -install_package() { - local package_name=$1 - local wait_time=${2:-5} # Default to 5 seconds if not specified - - echo "Installing $package_name..." - katana install "$package_name" - wait_for_service "$package_name installation" "$wait_time" -} - -# Standard start wrapper -# Arguments: -# $1: Package name -# $2: Initial wait time (optional, defaults to 10) -start_package() { - local package_name=$1 - local wait_time=${2:-10} # Default to 10 seconds if not specified - - echo "Starting $package_name..." - katana start "$package_name" - wait_for_service "$package_name startup" "$wait_time" -} - -# Standard cleanup wrapper -# Arguments: -# $1: Package name -# $2: Wait time before removal (optional, defaults to 5) -cleanup_package() { - local package_name=$1 - local wait_time=${2:-5} # Default to 5 seconds if not specified - - echo "Stopping $package_name..." - katana stop "$package_name" - wait_for_service "$package_name shutdown" "$wait_time" - - echo "Removing $package_name..." - katana remove "$package_name" -} diff --git a/test/provision-centos.sh b/test/provision-centos.sh deleted file mode 100755 index a7944e8..0000000 --- a/test/provision-centos.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -e - -yum install -y yum-utils - -if ! command -v docker; then - yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - - if [[ ! -x /usr/bin/docker-compose ]]; then - cat < /usr/bin/docker-compose -#!/bin/bash -exec docker compose "$@" -EOF - chmod +x /usr/bin/docker-compose - fi - - systemctl enable docker - systemctl start docker - usermod -a -G docker vagrant -fi - -# TODO: recent nodejs and yarn -yum install -y python3-pip git jq java-17-openjdk-headless nginx -systemctl enable nginx -systemctl start nginx - -mkdir -p /etc/samurai.d/{certs,applications}/ /opt/katana - -wget $(curl -s https://api.github.com/repos/FiloSottile/mkcert/releases/latest | jq -r ".assets[] | select(.name | test(\"linux-amd64\")) | .browser_download_url") -O mkcert -chmod +x ./mkcert -mv ./mkcert /usr/local/bin/mkcert -openssl genrsa -out /etc/samurai.d/certs/rootCAKey.pem 2048 -openssl req -x509 -sha256 -new -nodes -key /etc/samurai.d/certs/rootCAKey.pem -days 365 -out /etc/samurai.d/certs/rootCACert.pem -subj "/C=US/ST=Hacking/L=Springfield/O=SamuraiWTF/CN=samuraiwtf" -cp /etc/samurai.d/certs/rootCACert.pem /etc/pki/ca-trust/source/anchors/ -update-ca-trust -openssl req -new -newkey rsa:4096 -nodes -keyout /etc/samurai.d/certs/katana.test.key -out /etc/samurai.d/certs/katana.test.csr -subj "/C=US/ST=Hacking/L=Springfield/O=SamuraiWTF/CN=katana.test" - -pip3 install -r requirements.txt -cat > /usr/bin/katana < /dev/null - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - - systemctl enable docker - systemctl start docker - - # Add current user to docker group if we're in Vagrant - if id vagrant &>/dev/null; then - usermod -a -G docker vagrant - elif [ -n "$GITHUB_ACTIONS" ]; then - # In GitHub Actions, add the runner user to docker group - usermod -a -G docker $USER - fi -fi - -# Always ensure docker-compose wrapper exists and is executable -cat < /usr/local/bin/docker-compose -#!/bin/bash -exec docker compose "\$@" -EOF -chmod +x /usr/local/bin/docker-compose - -apt-get install -y python3-pip git jq openjdk-17-jdk-headless nginx yarnpkg -update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java -ln -sf /usr/bin/yarnpkg /usr/bin/yarn -systemctl enable nginx -systemctl start nginx - -mkdir -p /etc/samurai.d/{certs,applications}/ /opt/katana - -wget $(curl -s https://api.github.com/repos/FiloSottile/mkcert/releases/latest | jq -r ".assets[] | select(.name | test(\"linux-amd64\")) | .browser_download_url") -O mkcert -chmod +x ./mkcert -mv ./mkcert /usr/local/bin/mkcert -openssl genrsa -out /etc/samurai.d/certs/rootCAKey.pem 2048 -openssl req -x509 -sha256 -new -nodes -key /etc/samurai.d/certs/rootCAKey.pem -days 365 -out /etc/samurai.d/certs/rootCACert.pem -subj "/C=US/ST=Hacking/L=Springfield/O=SamuraiWTF/CN=samuraiwtf" -cp /etc/samurai.d/certs/rootCACert.pem /etc/ssl/certs -update-ca-certificates -openssl req -new -newkey rsa:4096 -nodes -keyout /etc/samurai.d/certs/katana.test.key -out /etc/samurai.d/certs/katana.test.csr -subj "/C=US/ST=Hacking/L=Springfield/O=SamuraiWTF/CN=katana.test" - -pip3 install -r requirements.txt -cat > /usr/bin/katana </dev/null 2>/dev/null - if [[ $? -eq 0 ]]; then - echo "PASSED" - else - echo "FAILED" - fi -done diff --git a/test/test-amoksecurity.sh b/test/test-amoksecurity.sh deleted file mode 100755 index 58d2424..0000000 --- a/test/test-amoksecurity.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install amoksecurity - -# Check if the directory exists and is empty -if [ ! -d "/var/www/amoksecurity" ]; then - echo "Error: /var/www/amoksecurity directory was not created" - exit 1 -fi - -# Count files in directory (excluding . and ..) -file_count=$(ls -A /var/www/amoksecurity | wc -l) -if [ "$file_count" -ne 0 ]; then - echo "Warning: /var/www/amoksecurity is not empty as expected" -fi - -# Create a test file -echo "Test content for amoksecurity" | sudo tee /var/www/amoksecurity/test.html > /dev/null - -# Function to check HTTP response -check_response() { - local url=$1 - local expected_code=$2 - local response=$(curl -s -w "%{http_code}" -o /dev/null "$url") - if [ "$response" = "$expected_code" ]; then - echo "Success: Got expected $expected_code response from $url" - return 0 - else - echo "Error: Expected $expected_code response from $url but got $response" - return 1 - fi -} - -# Test root paths - should get 403 -check_response "http://amoksecurity.test:80/" "403" || exit 1 -# check_response "http://amoksecurity.wtf:80/" "403" || exit 1 - -# Test our test file - should get 200 -check_response "http://amoksecurity.test:80/test.html" "200" || exit 1 -# check_response "http://amoksecurity.wtf:80/test.html" "200" || exit 1 - -# Verify content of test file -test_content=$(curl -s "http://amoksecurity.test:80/test.html") -if [ "$test_content" != "Test content for amoksecurity" ]; then - echo "Error: Test file content does not match expected content" - exit 1 -fi - -# Clean up -sudo rm /var/www/amoksecurity/test.html - -katana remove amoksecurity - -# Verify directory is removed during cleanup -if [ -d "/var/www/amoksecurity" ]; then - echo "Warning: /var/www/amoksecurity directory still exists after removal" -fi - -echo -e "\nPASSED\n" diff --git a/test/test-burpsuite.sh b/test/test-burpsuite.sh deleted file mode 100755 index 8dba453..0000000 --- a/test/test-burpsuite.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install burpsuite - -command -v burp - -katana remove burpsuite - -echo -e "\nPASSED\n" diff --git a/test/test-dojo-basic-lite.sh b/test/test-dojo-basic-lite.sh deleted file mode 100755 index 08e0590..0000000 --- a/test/test-dojo-basic-lite.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Source common test utilities -source "$(dirname "$0")/lib.sh" - -# Install and start the service -install_package dojo-basic-lite -start_package dojo-basic-lite - -# Test the endpoint -echo "Testing Dojo Basic Lite endpoint..." -test_endpoint "https://dojo-basic.test:8443/" - -# Cleanup -cleanup_package dojo-basic-lite - -echo -e "\nPASSED\n" diff --git a/test/test-dojo-scavenger-lite.sh b/test/test-dojo-scavenger-lite.sh deleted file mode 100755 index 9a9c047..0000000 --- a/test/test-dojo-scavenger-lite.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Source common test utilities -source "$(dirname "$0")/lib.sh" - -# Install and start the service -install_package dojo-scavenger-lite -start_package dojo-scavenger-lite - -# Test the endpoint -echo "Testing Dojo Scavenger Lite endpoint..." -test_endpoint "https://dojo-scavenger.test:8443/" - -# Cleanup -cleanup_package dojo-scavenger-lite - -echo -e "\nPASSED\n" diff --git a/test/test-dvga.sh b/test/test-dvga.sh deleted file mode 100755 index 4e8ef50..0000000 --- a/test/test-dvga.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install dvga -sleep 2 -katana start dvga - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:5013/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://dvga.test:8443/ - -katana stop dvga -sleep 2 -katana remove dvga - -echo -e "\nPASSED\n" diff --git a/test/test-dvwa.sh b/test/test-dvwa.sh deleted file mode 100755 index 87069fa..0000000 --- a/test/test-dvwa.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install dvwa -sleep 2 -katana start dvwa - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:31000/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://dvwa.test:8443/ - -katana stop dvwa -sleep 2 -katana remove dvwa - -echo -e "\nPASSED\n" diff --git a/test/test-ffuf.sh b/test/test-ffuf.sh deleted file mode 100755 index 46d3ddd..0000000 --- a/test/test-ffuf.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install ffuf - -ffuf -V - -katana remove ffuf - -echo -e "\nPASSED\n" diff --git a/test/test-juice-shop.sh b/test/test-juice-shop.sh deleted file mode 100755 index ef4195c..0000000 --- a/test/test-juice-shop.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install juice-shop -sleep 2 -katana start juice-shop - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:3000/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://juice-shop.test:8443/ - -katana stop juice-shop -sleep 2 -katana remove juice-shop - -echo -e "\nPASSED\n" diff --git a/test/test-musashi.sh b/test/test-musashi.sh deleted file mode 100755 index 4e698a5..0000000 --- a/test/test-musashi.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Source common test utilities -source "$(dirname "$0")/lib.sh" - -# Install and start the service -install_package musashi -start_package musashi 15 # Musashi needs a bit longer to start up - -# Test each endpoint -echo "Testing CORS Client endpoint..." -test_endpoint "https://cors-dojo.test:8443/" - -echo "Testing CORS API endpoint..." -test_endpoint "https://api.cors.test:8443/" 404 -- --no-fail - -echo "Testing JWT Demo endpoint..." -test_endpoint "https://jwt-demo.test:8443/" - -echo "Testing CSP Demo endpoint..." -test_endpoint "https://csp-dojo.test:8443/" - -# Cleanup -cleanup_package musashi - -echo -e "\nPASSED\n" diff --git a/test/test-nikto.sh b/test/test-nikto.sh deleted file mode 100755 index 370685f..0000000 --- a/test/test-nikto.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install nikto - -nikto --Version - -katana remove nikto - -echo -e "\nPASSED\n" diff --git a/test/test-plugin-labs.sh b/test/test-plugin-labs.sh deleted file mode 100755 index cf2268b..0000000 --- a/test/test-plugin-labs.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install plugin-labs -sleep 2 -katana start plugin-labs - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:33180/ -curl --fail -o /dev/null --retry 5 --retry-all-errors http://plugin-labs.wtf:80/ - -katana stop plugin-labs -sleep 2 -katana remove plugin-labs - -echo -e "\nPASSED\n" diff --git a/test/test-postman.sh b/test/test-postman.sh deleted file mode 100755 index 3851d61..0000000 --- a/test/test-postman.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install postman - -command -v postman - -katana remove postman - -echo -e "\nPASSED\n" diff --git a/test/test-sqlmap.sh b/test/test-sqlmap.sh deleted file mode 100755 index 62ee0ab..0000000 --- a/test/test-sqlmap.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install sqlmap - -sqlmap --version - -katana remove sqlmap - -echo -e "\nPASSED\n" diff --git a/test/test-ssrf.sh b/test/test-ssrf.sh deleted file mode 100755 index b10713c..0000000 --- a/test/test-ssrf.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install ssrf -sleep 2 -katana start ssrf - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:8000/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://ssrf.test:8443/ - -katana stop ssrf -sleep 2 -katana remove ssrf - -echo -e "\nPASSED\n" diff --git a/test/test-trufflehog.sh b/test/test-trufflehog.sh deleted file mode 100755 index 65ff198..0000000 --- a/test/test-trufflehog.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install trufflehog - -trufflehog --version - -katana remove trufflehog - -echo -e "\nPASSED\n" diff --git a/test/test-vapi.sh b/test/test-vapi.sh deleted file mode 100755 index db57059..0000000 --- a/test/test-vapi.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Source common test utilities -source "$(dirname "$0")/lib.sh" - -# Install and start the service -install_package vapi -start_package vapi - -# Test the endpoint -echo "Testing VAPI endpoint..." -test_endpoint "https://vapi.test:8443/" - -# Cleanup -cleanup_package vapi - -echo -e "\nPASSED\n" diff --git a/test/test-wayfarer.sh b/test/test-wayfarer.sh deleted file mode 100755 index cfbe164..0000000 --- a/test/test-wayfarer.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install wayfarer -sleep 2 -katana start wayfarer - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:7000/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://wayfarer.test:8443/ - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:7001/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://api.wayfarer.test:8443/ - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:3002/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://auth.wayfarer.test:8443/ - -katana stop wayfarer -sleep 2 -katana remove wayfarer - -echo -e "\nPASSED\n" diff --git a/test/test-wordlists.sh b/test/test-wordlists.sh deleted file mode 100755 index bd498dc..0000000 --- a/test/test-wordlists.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install wordlists - -test -f /opt/samurai/wordlists/fuzzdb/README.md -test -f /opt/samurai/wordlists/seclists/README.md - -katana remove wordlists - -echo -e "\nPASSED\n" diff --git a/test/test-wrongsecrets.sh b/test/test-wrongsecrets.sh deleted file mode 100755 index 72b1ea4..0000000 --- a/test/test-wrongsecrets.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install wrongsecrets -sleep 2 -katana start wrongsecrets - -curl --fail -o /dev/null --retry 5 --retry-all-errors http://localhost:31500/ -curl --fail -o /dev/null --retry 5 --retry-all-errors -k https://wrongsecrets.test:8443/ - -katana stop wrongsecrets -sleep 2 -katana remove wrongsecrets - -echo -e "\nPASSED\n" diff --git a/test/test-zap.sh b/test/test-zap.sh deleted file mode 100755 index b64c5a3..0000000 --- a/test/test-zap.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -katana install zap - -/opt/samurai/ZAP_2.16.0/zap.sh -cmd -version - -katana remove zap - -echo -e "\nPASSED\n" diff --git a/tests/e2e/api.sh b/tests/e2e/api.sh new file mode 100644 index 0000000..f461685 --- /dev/null +++ b/tests/e2e/api.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Katana 2 E2E Tests - API Endpoints +# Category A4: Verifies REST API endpoints work correctly +# REQUIRES: Proxy running on https://katana.test + +set -e +cd "$(dirname "$0")/../.." + +BASE="https://katana.test" +# Use --resolve to bypass /etc/hosts for testing (avoids needing sudo dns sync) +CURL="curl -sk --connect-timeout 5 --resolve katana.test:443:127.0.0.1" + +echo "==========================================" +echo "A4: API Endpoint Tests" +echo "==========================================" + +# Check if proxy is reachable +echo "" +echo "Checking proxy connectivity..." +if ! $CURL "$BASE" > /dev/null 2>&1; then + echo "ERROR: Cannot connect to $BASE" + echo "Make sure the proxy is running: ./bin/katana proxy start" + exit 1 +fi +echo " Proxy is reachable" + +# Cleanup function +cleanup() { + echo "" + echo "Cleanup..." + $CURL -X POST "$BASE/api/system/unlock" > /dev/null 2>&1 || true + $CURL -X POST "$BASE/api/modules/dvwa/remove" > /dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "" +echo "A4.1: List modules (GET /api/modules)..." +MODULES=$($CURL "$BASE/api/modules" 2>&1) +if echo "$MODULES" | grep -q "dvwa"; then + echo " PASS (found dvwa in response)" +else + echo " FAIL: dvwa not in response" + echo " Response: $MODULES" + exit 1 +fi + +echo "" +echo "A4.2: System status (GET /api/system)..." +STATUS=$($CURL "$BASE/api/system" 2>&1) +if echo "$STATUS" | grep -q "docker"; then + echo " PASS (got system status)" +else + echo " FAIL: unexpected response" + echo " Response: $STATUS" + exit 1 +fi + +echo "" +echo "A4.3: Install target (POST /api/modules/dvwa/install)..." +INSTALL=$($CURL -X POST "$BASE/api/modules/dvwa/install" 2>&1) +if echo "$INSTALL" | grep -q -E "(id|operation|success)"; then + OP_ID=$(echo "$INSTALL" | grep -oE '"id"\s*:\s*"[^"]*"' | head -1 | cut -d'"' -f4) + echo " PASS (operation started: $OP_ID)" +else + echo " Response: $INSTALL" + echo " Checking if already installed..." +fi + +echo " Waiting for install to complete (30s max)..." +for i in {1..30}; do + sleep 1 + if docker ps | grep -q "katana-dvwa"; then + echo " Containers running after ${i}s" + break + fi +done + +echo "" +echo "A4.4: Operation status (GET /api/operations/:id)..." +if [ -n "$OP_ID" ]; then + OP_STATUS=$($CURL "$BASE/api/operations/$OP_ID" 2>&1) + if echo "$OP_STATUS" | grep -q -E "(status|progress|complete)"; then + echo " PASS (got operation status)" + else + echo " WARN: could not get operation status" + fi +else + echo " SKIP (no operation ID)" +fi + +echo "" +echo "A4.5: Stop target (POST /api/modules/dvwa/stop)..." +STOP=$($CURL -X POST "$BASE/api/modules/dvwa/stop" 2>&1) +if echo "$STOP" | grep -q -E "(success|id|operation)"; then + echo " PASS" +else + echo " Response: $STOP" +fi +sleep 3 + +echo "" +echo "A4.6: Start target (POST /api/modules/dvwa/start)..." +START=$($CURL -X POST "$BASE/api/modules/dvwa/start" 2>&1) +if echo "$START" | grep -q -E "(success|id|operation)"; then + echo " PASS" +else + echo " Response: $START" +fi +sleep 3 + +echo "" +echo "A4.7: Remove target (POST /api/modules/dvwa/remove)..." +REMOVE=$($CURL -X POST "$BASE/api/modules/dvwa/remove" 2>&1) +if echo "$REMOVE" | grep -q -E "(success|id|operation)"; then + echo " PASS" +else + echo " Response: $REMOVE" +fi +sleep 5 + +echo "" +echo "A4.8: Lock system (POST /api/system/lock)..." +LOCK=$($CURL -X POST "$BASE/api/system/lock" 2>&1) +if echo "$LOCK" | grep -q -E "(locked|success|true)"; then + echo " PASS" +else + echo " Response: $LOCK" +fi + +echo "" +echo "A4.11: Install when locked (should fail)..." +LOCKED_INSTALL=$($CURL -X POST "$BASE/api/modules/dvwa/install" 2>&1) +if echo "$LOCKED_INSTALL" | grep -q -E "(locked|error|403|cannot)"; then + echo " PASS (correctly rejected)" +else + echo " FAIL: should have been rejected" + echo " Response: $LOCKED_INSTALL" +fi + +echo "" +echo "A4.9: Unlock system (POST /api/system/unlock)..." +UNLOCK=$($CURL -X POST "$BASE/api/system/unlock" 2>&1) +if echo "$UNLOCK" | grep -q -E "(unlock|success|false)"; then + echo " PASS" +else + echo " Response: $UNLOCK" +fi + +echo "" +echo "A4.10: Download CA (GET /api/certs/ca)..." +$CURL "$BASE/api/certs/ca" -o /tmp/test-katana-ca.crt 2>&1 +if [ -s /tmp/test-katana-ca.crt ]; then + if openssl x509 -in /tmp/test-katana-ca.crt -noout -subject 2>/dev/null; then + echo " PASS (valid certificate)" + else + echo " FAIL: invalid certificate format" + exit 1 + fi + rm -f /tmp/test-katana-ca.crt +else + echo " FAIL: empty or missing file" + exit 1 +fi + +# Cleanup already handled by trap +trap - EXIT +$CURL -X POST "$BASE/api/system/unlock" > /dev/null 2>&1 || true + +echo "" +echo "==========================================" +echo "A4: All API tests PASSED" +echo "==========================================" diff --git a/tests/e2e/build.sh b/tests/e2e/build.sh new file mode 100644 index 0000000..a66c91c --- /dev/null +++ b/tests/e2e/build.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Katana 2 E2E Tests - Build Verification +# Category A1: Verifies that the project builds correctly + +set -e +cd "$(dirname "$0")/../.." + +echo "==========================================" +echo "A1: Build Verification Tests" +echo "==========================================" + +echo "" +echo "A1.1: TypeScript compilation..." +bunx tsc --noEmit +echo " PASS" + +echo "" +echo "A1.2: Biome linting..." +bunx biome check src/ +echo " PASS" + +echo "" +echo "A1.3: CLI build..." +bun build --compile src/cli.ts --outfile bin/katana +if [ -f bin/katana ]; then + echo " PASS ($(ls -lh bin/katana | awk '{print $5}'))" +else + echo " FAIL: bin/katana not created" + exit 1 +fi + +echo "" +echo "A1.4: UI build..." +bun run build:ui +if [ -d src/ui/dist ] && [ -f src/ui/dist/index.html ]; then + echo " PASS ($(ls src/ui/dist/*.js 2>/dev/null | wc -l) JS files)" +else + echo " FAIL: src/ui/dist/ not populated correctly" + exit 1 +fi + +echo "" +echo "A1.5: CLI version..." +VERSION=$(./bin/katana --version 2>&1) +if echo "$VERSION" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+"; then + echo " PASS ($VERSION)" +else + echo " FAIL: unexpected version output: $VERSION" + exit 1 +fi + +echo "" +echo "==========================================" +echo "A1: All build tests PASSED" +echo "==========================================" diff --git a/tests/e2e/cli.sh b/tests/e2e/cli.sh new file mode 100644 index 0000000..f16ab98 --- /dev/null +++ b/tests/e2e/cli.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Katana 2 E2E Tests - CLI Commands +# Category A2: Verifies CLI commands work correctly + +set -e +cd "$(dirname "$0")/../.." + +KATANA="./bin/katana" + +if [ ! -f "$KATANA" ]; then + echo "ERROR: $KATANA not found. Run build.sh first." + exit 1 +fi + +echo "==========================================" +echo "A2: CLI Command Tests" +echo "==========================================" + +echo "" +echo "A2.1: List targets..." +OUTPUT=$($KATANA list targets 2>&1) +if echo "$OUTPUT" | grep -qi "dvwa"; then + echo " PASS (found dvwa)" +else + echo " FAIL: dvwa not found in output" + echo " Output: $OUTPUT" + exit 1 +fi + +echo "" +echo "A2.2: List tools..." +$KATANA list tools > /dev/null 2>&1 +echo " PASS (no error)" + +echo "" +echo "A2.3: Status..." +OUTPUT=$($KATANA status 2>&1) +if echo "$OUTPUT" | grep -qi "target"; then + echo " PASS" +else + echo " FAIL: unexpected status output" + echo " Output: $OUTPUT" + exit 1 +fi + +echo "" +echo "A2.4: Doctor..." +OUTPUT=$($KATANA doctor 2>&1) || true # May have failures, that's OK +if echo "$OUTPUT" | grep -qi "docker"; then + echo " PASS (doctor ran)" +else + echo " FAIL: unexpected doctor output" + echo " Output: $OUTPUT" + exit 1 +fi + +echo "" +echo "A2.5: Cert status..." +OUTPUT=$($KATANA cert status 2>&1) || true +if echo "$OUTPUT" | grep -qi -E "(certificate|cert|CA|not initialized)"; then + echo " PASS" +else + echo " FAIL: unexpected cert status output" + echo " Output: $OUTPUT" + exit 1 +fi + +echo "" +echo "A2.6: DNS list..." +$KATANA dns list > /dev/null 2>&1 || true +echo " PASS (no crash)" + +echo "" +echo "A2.7-8: Lock/Unlock..." +$KATANA lock +OUTPUT=$($KATANA status 2>&1) +if echo "$OUTPUT" | grep -qi "locked.*yes\|locked: true"; then + echo " Lock: PASS" +else + echo " Lock: FAIL - status doesn't show locked" + echo " Output: $OUTPUT" +fi + +$KATANA unlock +OUTPUT=$($KATANA status 2>&1) +if echo "$OUTPUT" | grep -qi "locked.*no\|locked: false"; then + echo " Unlock: PASS" +else + echo " Unlock: FAIL - status doesn't show unlocked" + echo " Output: $OUTPUT" +fi + +echo "" +echo "==========================================" +echo "A2: All CLI tests PASSED" +echo "==========================================" diff --git a/tests/e2e/lifecycle.sh b/tests/e2e/lifecycle.sh new file mode 100644 index 0000000..e217a6e --- /dev/null +++ b/tests/e2e/lifecycle.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Katana 2 E2E Tests - Target Lifecycle +# Category A3: Verifies target install/start/stop/remove workflow + +set -e +cd "$(dirname "$0")/../.." + +KATANA="./bin/katana" + +if [ ! -f "$KATANA" ]; then + echo "ERROR: $KATANA not found. Run build.sh first." + exit 1 +fi + +# Check Docker is running +if ! docker info > /dev/null 2>&1; then + echo "ERROR: Docker is not running" + exit 1 +fi + +echo "==========================================" +echo "A3: Target Lifecycle Tests" +echo "==========================================" + +# Cleanup function +cleanup() { + echo "" + echo "Cleanup: removing test target..." + $KATANA remove dvwa 2>/dev/null || true +} +trap cleanup EXIT + +# Make sure we start clean +$KATANA remove dvwa 2>/dev/null || true +sleep 2 + +echo "" +echo "A3.1: Install DVWA..." +$KATANA install dvwa +echo " Waiting for containers to be created..." +sleep 5 + +# Verify containers exist (but are stopped after install) +if docker ps -a | grep -q "katana-dvwa"; then + echo " PASS (containers created)" +else + echo " FAIL: containers not found" + docker ps -a + exit 1 +fi + +# Verify containers are NOT running yet +if docker ps | grep -q "katana-dvwa"; then + echo " FAIL: containers should not be running after install" + exit 1 +else + echo " PASS (containers stopped as expected)" +fi + +echo "" +echo "A3.1b: Start installed target..." +$KATANA start dvwa +sleep 5 + +# Now verify containers are running +if docker ps | grep -q "katana-dvwa"; then + echo " PASS (containers running after explicit start)" +else + echo " FAIL: containers not running after start" + docker ps + exit 1 +fi + +echo "" +echo "A3.2: Verify installed..." +OUTPUT=$($KATANA list --installed 2>&1) +if echo "$OUTPUT" | grep -qi "dvwa"; then + echo " PASS (dvwa in installed list)" +else + echo " FAIL: dvwa not in installed list" + echo " Output: $OUTPUT" + exit 1 +fi + +echo "" +echo "A3.3: Stop target..." +$KATANA stop dvwa +sleep 3 + +OUTPUT=$($KATANA status 2>&1) +if echo "$OUTPUT" | grep -qi "stopped\|exited"; then + echo " PASS (target stopped)" +else + echo " Checking docker status..." + docker ps -a --filter "name=katana-dvwa" +fi + +echo "" +echo "A3.4: Start target..." +$KATANA start dvwa +sleep 5 + +if docker ps | grep -q "katana-dvwa"; then + echo " PASS (target started)" +else + echo " FAIL: target not running" + exit 1 +fi + +echo "" +echo "A3.5: View logs..." +OUTPUT=$($KATANA logs dvwa --tail 5 2>&1) || true +if [ -n "$OUTPUT" ]; then + echo " PASS (got log output)" + echo " Sample: $(echo "$OUTPUT" | head -2)" +else + echo " WARN: no log output (may be expected)" +fi + +echo "" +echo "A3.6: Remove target..." +$KATANA remove dvwa +sleep 3 + +if ! docker ps -a | grep -q "katana-dvwa"; then + echo " PASS (containers removed)" +else + echo " FAIL: containers still exist" + docker ps -a | grep katana-dvwa + exit 1 +fi + +echo "" +echo "A3.7: Verify removed..." +OUTPUT=$($KATANA list --installed 2>&1) +if ! echo "$OUTPUT" | grep -qi "dvwa.*installed\|^\s*dvwa\s*$"; then + echo " PASS (dvwa not in installed list)" +else + echo " FAIL: dvwa still in installed list" + echo " Output: $OUTPUT" + exit 1 +fi + +# Disable cleanup trap since we cleaned up in A3.6 +trap - EXIT + +echo "" +echo "==========================================" +echo "A3: All lifecycle tests PASSED" +echo "==========================================" diff --git a/tests/e2e/proxy.sh b/tests/e2e/proxy.sh new file mode 100644 index 0000000..6671005 --- /dev/null +++ b/tests/e2e/proxy.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Katana 2 E2E Tests - Proxy Routing +# Category A5: Verifies reverse proxy routing works correctly +# REQUIRES: Proxy running, may install DVWA temporarily + +set -e +cd "$(dirname "$0")/../.." + +KATANA="./bin/katana" +# Use --resolve to bypass /etc/hosts for testing (avoids needing sudo dns sync) +CURL="curl -sk --connect-timeout 5 --resolve katana.test:443:127.0.0.1 --resolve katana.test:80:127.0.0.1 --resolve dvwa.test:443:127.0.0.1 --resolve dvwa.test:80:127.0.0.1 --resolve nonexistent.test:443:127.0.0.1" + +echo "==========================================" +echo "A5: Proxy Routing Tests" +echo "==========================================" + +# Check proxy connectivity first +echo "" +echo "Checking proxy connectivity..." +if ! $CURL "https://katana.test" > /dev/null 2>&1; then + echo "ERROR: Cannot connect to https://katana.test" + echo "Make sure:" + echo " 1. Proxy is running: ./bin/katana proxy start" + echo " 2. DNS is configured: sudo ./bin/katana dns sync" + echo " 3. Certificates are initialized: ./bin/katana cert init" + exit 1 +fi +echo " Proxy is reachable" + +# Install DVWA for routing test +echo "" +echo "Setting up DVWA for routing test..." +$KATANA install dvwa 2>/dev/null || true +echo " Waiting for containers..." +sleep 10 + +cleanup() { + echo "" + echo "Cleanup: removing DVWA..." + $KATANA remove dvwa 2>/dev/null || true +} +trap cleanup EXIT + +echo "" +echo "A5.1: Dashboard accessible (https://katana.test)..." +RESPONSE=$($CURL "https://katana.test" 2>&1) +if echo "$RESPONSE" | grep -qi -E "(html|katana|&1) || true +if [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "308" ]; then + echo " PASS (got redirect: $HTTP_CODE)" +else + # Check if we got redirected to HTTPS + LOCATION=$($CURL -I "http://katana.test" 2>&1 | grep -i "location:" | head -1) + if echo "$LOCATION" | grep -qi "https://"; then + echo " PASS (redirects to HTTPS)" + else + echo " WARN: HTTP code $HTTP_CODE (may be OK if redirect works)" + fi +fi + +echo "" +echo "A5.3: Target routing (https://dvwa.test)..." +DVWA_RESPONSE=$($CURL "https://dvwa.test" 2>&1) +if echo "$DVWA_RESPONSE" | grep -qi -E "(dvwa|damn vulnerable|login|html)"; then + echo " PASS (DVWA responding)" +else + echo " FAIL: DVWA not responding correctly" + echo " Response: $(echo "$DVWA_RESPONSE" | head -10)" + echo "" + echo " Checking container status..." + docker ps | grep katana-dvwa || echo " No DVWA containers found" + exit 1 +fi + +echo "" +echo "A5.4: Unknown host (https://nonexistent.test)..." +# This test requires nonexistent.test to resolve to 127.0.0.1 +# Add it temporarily if needed +if ! grep -q "nonexistent.test" /etc/hosts 2>/dev/null; then + echo " Note: nonexistent.test not in /etc/hosts, testing via Host header" + HTTP_CODE=$($CURL -H "Host: nonexistent.test" -w "%{http_code}" -o /dev/null "https://katana.test" 2>&1) || true +else + HTTP_CODE=$($CURL -w "%{http_code}" -o /dev/null "https://nonexistent.test" 2>&1) || true +fi + +if [ "$HTTP_CODE" = "404" ]; then + echo " PASS (got 404)" +else + echo " Response code: $HTTP_CODE (expected 404, may vary based on implementation)" +fi + +echo "" +echo "A5.5: API via proxy (https://katana.test/api/system)..." +API_RESPONSE=$($CURL "https://katana.test/api/system" 2>&1) +if echo "$API_RESPONSE" | grep -q "docker"; then + echo " PASS (API responding)" +else + echo " FAIL: API not responding correctly" + echo " Response: $API_RESPONSE" + exit 1 +fi + +echo "" +echo "==========================================" +echo "A5: All proxy routing tests PASSED" +echo "==========================================" diff --git a/tests/e2e/run-all.sh b/tests/e2e/run-all.sh new file mode 100644 index 0000000..71ea12d --- /dev/null +++ b/tests/e2e/run-all.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# Katana 2 E2E Tests - Master Runner +# Runs all automated tests in sequence + +set -e +cd "$(dirname "$0")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +PASSED=0 +FAILED=0 +SKIPPED=0 + +# Results array +declare -a RESULTS + +run_test() { + local name=$1 + local script=$2 + local requires_proxy=${3:-false} + + echo "" + echo "============================================" + echo "Running: $name" + echo "============================================" + + if [ "$requires_proxy" = "true" ]; then + # Check if proxy is running (use --resolve to bypass /etc/hosts) + if ! curl -sk --connect-timeout 2 --resolve katana.test:443:127.0.0.1 "https://katana.test" > /dev/null 2>&1; then + echo -e "${YELLOW}SKIPPED${NC}: Proxy not running" + RESULTS+=("$name: SKIPPED (proxy required)") + SKIPPED=$((SKIPPED + 1)) + return + fi + fi + + if bash "$script"; then + echo -e "${GREEN}PASSED${NC}: $name" + RESULTS+=("$name: PASSED") + PASSED=$((PASSED + 1)) + else + echo -e "${RED}FAILED${NC}: $name" + RESULTS+=("$name: FAILED") + FAILED=$((FAILED + 1)) + fi +} + +echo "==========================================" +echo "Katana 2 End-to-End Test Suite" +echo "==========================================" +echo "Date: $(date)" +echo "Working directory: $(pwd)" +echo "" + +# Check prerequisites +echo "Checking prerequisites..." + +if ! command -v bun &> /dev/null; then + echo "ERROR: bun is not installed" + exit 1 +fi +echo " Bun: $(bun --version)" + +if ! docker info > /dev/null 2>&1; then + echo "ERROR: Docker is not running" + exit 1 +fi +echo " Docker: OK" + +if [ ! -f "../bin/katana" ] && [ ! -f "../../bin/katana" ]; then + echo " Katana binary: Not found (will be built)" +else + echo " Katana binary: OK" +fi + +echo "" +echo "Starting tests..." + +# Run tests in sequence +# A1: Build (no proxy required) +run_test "A1: Build Verification" "build.sh" false + +# A2: CLI Commands (no proxy required) +run_test "A2: CLI Commands" "cli.sh" false + +# A6: State Management (no proxy required) +run_test "A6: State Management" "state.sh" false + +# A3: Target Lifecycle (no proxy required, but needs Docker) +run_test "A3: Target Lifecycle" "lifecycle.sh" false + +# A4: API Endpoints (requires proxy) +run_test "A4: API Endpoints" "api.sh" true + +# A5: Proxy Routing (requires proxy) +run_test "A5: Proxy Routing" "proxy.sh" true + +# Summary +echo "" +echo "==========================================" +echo "TEST SUMMARY" +echo "==========================================" +echo "" + +for result in "${RESULTS[@]}"; do + if [[ $result == *"PASSED"* ]]; then + echo -e "${GREEN}[PASS]${NC} ${result%: PASSED}" + elif [[ $result == *"FAILED"* ]]; then + echo -e "${RED}[FAIL]${NC} ${result%: FAILED}" + else + echo -e "${YELLOW}[SKIP]${NC} ${result%: SKIPPED*}" + fi +done + +echo "" +echo "----------------------------------------" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo -e "Skipped: ${YELLOW}$SKIPPED${NC}" +echo "----------------------------------------" + +if [ $FAILED -gt 0 ]; then + echo "" + echo -e "${RED}Some tests FAILED${NC}" + exit 1 +else + echo "" + echo -e "${GREEN}All tests PASSED${NC}" + exit 0 +fi diff --git a/tests/e2e/state.sh b/tests/e2e/state.sh new file mode 100644 index 0000000..d54222d --- /dev/null +++ b/tests/e2e/state.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# Katana 2 E2E Tests - State Management +# Category A6: Verifies state file is correctly maintained + +set -e +cd "$(dirname "$0")/../.." + +KATANA="./bin/katana" +STATE_FILE="$HOME/.local/share/katana/state.yml" + +if [ ! -f "$KATANA" ]; then + echo "ERROR: $KATANA not found. Run build.sh first." + exit 1 +fi + +echo "==========================================" +echo "A6: State Management Tests" +echo "==========================================" + +# Ensure state directory exists +mkdir -p "$(dirname "$STATE_FILE")" + +echo "" +echo "A6.1: State file exists..." +# Initialize if needed +if [ ! -f "$STATE_FILE" ]; then + echo " Creating initial state..." + $KATANA status > /dev/null 2>&1 || true +fi + +if [ -f "$STATE_FILE" ]; then + echo " PASS ($STATE_FILE exists)" +else + echo " FAIL: state file not created" + exit 1 +fi + +echo "" +echo "A6.2: State valid YAML..." +# Use bun to validate YAML structure +VALID=$(bun -e " +const yaml = require('yaml'); +const fs = require('fs'); +try { + const content = fs.readFileSync('$STATE_FILE', 'utf8'); + const parsed = yaml.parse(content); + if (typeof parsed === 'object' && parsed !== null) { + console.log('valid'); + } else { + console.log('invalid: not an object'); + } +} catch (e) { + console.log('invalid: ' + e.message); +} +" 2>&1) + +if [ "$VALID" = "valid" ]; then + echo " PASS (valid YAML structure)" +else + echo " FAIL: $VALID" + exit 1 +fi + +echo "" +echo "A6.3: Install updates state..." +# Clean start +$KATANA remove dvwa 2>/dev/null || true +sleep 2 + +$KATANA install dvwa +sleep 5 + +if grep -q "dvwa" "$STATE_FILE"; then + echo " PASS (dvwa in state file)" +else + echo " FAIL: dvwa not found in state file" + cat "$STATE_FILE" + exit 1 +fi + +echo "" +echo "A6.4: Remove updates state..." +$KATANA remove dvwa +sleep 2 + +# Check that dvwa entry is removed (not just the word "dvwa" anywhere) +if grep -q "name: dvwa" "$STATE_FILE"; then + echo " FAIL: dvwa still in state file" + cat "$STATE_FILE" + exit 1 +else + echo " PASS (dvwa removed from state)" +fi + +echo "" +echo "A6.5: Lock state persisted..." +$KATANA lock + +if grep -q "locked: true" "$STATE_FILE"; then + echo " Lock PASS" +else + echo " Lock FAIL: state file doesn't show locked" + grep locked "$STATE_FILE" || echo " No 'locked' field found" +fi + +$KATANA unlock + +if grep -q "locked: false" "$STATE_FILE"; then + echo " Unlock PASS" +else + echo " Unlock FAIL: state file doesn't show unlocked" + grep locked "$STATE_FILE" || echo " No 'locked' field found" +fi + +echo "" +echo "A6.6: State survives restart..." +# Get current state hash +BEFORE=$(md5sum "$STATE_FILE" | cut -d' ' -f1) + +# Run status (should not modify state) +$KATANA status > /dev/null 2>&1 + +AFTER=$(md5sum "$STATE_FILE" | cut -d' ' -f1) + +if [ "$BEFORE" = "$AFTER" ]; then + echo " PASS (state unchanged by read operations)" +else + echo " WARN: state was modified by status command" +fi + +echo "" +echo "==========================================" +echo "A6: All state tests PASSED" +echo "==========================================" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ed53018 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun-types"], + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "bin"] +}